From e89da31e127f542f4ea0397b5e582dbab9d5f1af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A1=8C=E8=A8=80?= <2311595895@qq.com> Date: Tue, 26 Mar 2024 10:18:37 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(CategorySearch):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=88=86=E7=B1=BB=E6=90=9C=E7=B4=A2=E7=BB=84=E4=BB=B6=20(#1802?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../devui-vue/devui/category-search/index.ts | 15 + .../src/category-search-const.ts | 112 +++ .../src/category-search-types.ts | 282 +++++++ .../category-search/src/category-search.scss | 417 ++++++++++ .../category-search/src/category-search.tsx | 62 ++ .../src/components/category-search-clear.tsx | 24 + .../src/components/category-search-icons.tsx | 67 ++ .../src/components/category-search-input.tsx | 181 +++++ .../src/components/category-search-more.tsx | 40 + .../src/components/category-search-save.tsx | 55 ++ .../category-search-tag-dropdown.tsx | 54 ++ .../src/components/category-search-tag.tsx | 43 ++ .../src/components/checkbox-menu.tsx | 42 + .../src/components/label-menu.tsx | 62 ++ .../src/components/number-range-menu.tsx | 54 ++ .../src/components/radio-menu.tsx | 29 + .../src/components/text-input-menu.tsx | 45 ++ .../composables/use-category-search-icons.ts | 54 ++ .../composables/use-category-search-input.ts | 32 + .../src/composables/use-category-search.ts | 731 ++++++++++++++++++ .../docs/components/category-search/index.md | 529 +++++++++++++ 21 files changed, 2930 insertions(+) create mode 100644 packages/devui-vue/devui/category-search/index.ts create mode 100644 packages/devui-vue/devui/category-search/src/category-search-const.ts create mode 100644 packages/devui-vue/devui/category-search/src/category-search-types.ts create mode 100644 packages/devui-vue/devui/category-search/src/category-search.scss create mode 100644 packages/devui-vue/devui/category-search/src/category-search.tsx create mode 100644 packages/devui-vue/devui/category-search/src/components/category-search-clear.tsx create mode 100644 packages/devui-vue/devui/category-search/src/components/category-search-icons.tsx create mode 100644 packages/devui-vue/devui/category-search/src/components/category-search-input.tsx create mode 100644 packages/devui-vue/devui/category-search/src/components/category-search-more.tsx create mode 100644 packages/devui-vue/devui/category-search/src/components/category-search-save.tsx create mode 100644 packages/devui-vue/devui/category-search/src/components/category-search-tag-dropdown.tsx create mode 100644 packages/devui-vue/devui/category-search/src/components/category-search-tag.tsx create mode 100644 packages/devui-vue/devui/category-search/src/components/checkbox-menu.tsx create mode 100644 packages/devui-vue/devui/category-search/src/components/label-menu.tsx create mode 100644 packages/devui-vue/devui/category-search/src/components/number-range-menu.tsx create mode 100644 packages/devui-vue/devui/category-search/src/components/radio-menu.tsx create mode 100644 packages/devui-vue/devui/category-search/src/components/text-input-menu.tsx create mode 100644 packages/devui-vue/devui/category-search/src/composables/use-category-search-icons.ts create mode 100644 packages/devui-vue/devui/category-search/src/composables/use-category-search-input.ts create mode 100644 packages/devui-vue/devui/category-search/src/composables/use-category-search.ts create mode 100644 packages/devui-vue/docs/components/category-search/index.md diff --git a/packages/devui-vue/devui/category-search/index.ts b/packages/devui-vue/devui/category-search/index.ts new file mode 100644 index 0000000000..9fe126609c --- /dev/null +++ b/packages/devui-vue/devui/category-search/index.ts @@ -0,0 +1,15 @@ +import type { App } from 'vue'; +import CategorySearch from './src/category-search'; + +export * from './src/category-search-types'; + +export { CategorySearch }; + +export default { + title: 'CategorySearch 分类搜索', + category: '演进中', + status: '100%', + install(app: App): void { + app.component(CategorySearch.name, CategorySearch); + } +} \ No newline at end of file diff --git a/packages/devui-vue/devui/category-search/src/category-search-const.ts b/packages/devui-vue/devui/category-search/src/category-search-const.ts new file mode 100644 index 0000000000..6f29379a6b --- /dev/null +++ b/packages/devui-vue/devui/category-search/src/category-search-const.ts @@ -0,0 +1,112 @@ +export const DELAY = 300; +export const SearchKeyField = 'devuiCategorySearchKeyword'; +export const DROPDOWN_ANIMATION_TIMEOUT = 200; +export const COLORS = [ + '#f2f5fc', + '#e9edfa', + '#beccfa', + '#96adfa', + '#7693f5', + '#5e7ce0', + '#526ecc', + '#465eb8', + '#3c51a6', + '#344899', + '#2a3c85', + '#ebf6ff', + '#d1ebff', + '#b8e0ff', + '#9ed5ff', + '#85caff', + '#6cbfff', + '#4ea6e6', + '#3590cc', + '#207ab3', + '#0f6999', + '#035880', + '#edfff9', + '#cffcee', + '#acf2dc', + '#8be8cb', + '#6ddebb', + '#50d4ab', + '#3ac295', + '#27b080', + '#169e6c', + '#088c58', + '#007a45', + '#f0ffe6', + '#e5ffd4', + '#d8fcc0', + '#c5f2a7', + '#b3e890', + '#a6dd82', + '#92cc68', + '#7eba50', + '#6ca83b', + '#5e9629', + '#518519', + '#fffbf0', + '#fff1c2', + '#ffe794', + '#ffdc66', + '#ffd138', + '#fac20a', + '#e3aa00', + '#cc9600', + '#b58200', + '#9e6f00', + '#875c00', + '#fff3e8', + '#ffe1c7', + '#ffd0a6', + '#ffbf85', + '#ffad63', + '#fa9841', + '#e37d29', + '#cc6414', + '#b54e04', + '#9e3f00', + '#873400', + '#ffeeed', + '#ffd5d4', + '#ffbcba', + '#ffa4a1', + '#ff8b87', + '#f66f6a', + '#de504e', + '#c73636', + '#b02121', + '#991111', + '#820404', + '#ffedf3', + '#ffd4e3', + '#ffbad2', + '#ffa1c2', + '#fc86b0', + '#f3689a', + '#db4d83', + '#c4356e', + '#ad215b', + '#96114d', + '#800440', + '#f5f0ff', + '#e7d9ff', + '#d8c2ff', + '#caabff', + '#bc94ff', + '#a97af8', + '#8a5ce0', + '#6f42c9', + '#572db3', + '#3f1a9c', + '#2a0c85', +]; + +export function getSearchMessage(type: string) { + return `仅搜索关键字 '${type}'`; +} + +export function getFindingMessage(msg: string) { + return `在 '${msg}' 中查找`; +} \ No newline at end of file diff --git a/packages/devui-vue/devui/category-search/src/category-search-types.ts b/packages/devui-vue/devui/category-search/src/category-search-types.ts new file mode 100644 index 0000000000..03e2431639 --- /dev/null +++ b/packages/devui-vue/devui/category-search/src/category-search-types.ts @@ -0,0 +1,282 @@ +import type { ExtractPropTypes, PropType, InjectionKey, SetupContext, Ref } from 'vue'; + +export type CategorySearchTagType = 'radio' | 'checkbox' | 'label' | 'textInput' | 'numberRange' | 'keyword'; +export type StyleType = 'default' | 'gray'; +export interface ITagOption { + /** + * 选项,label和color默认都会取对应的 filterKey 和 colorKey,如未设置取默认值 + */ + label?: string; // 通用默认属性,用于设置分类名称 + color?: string; // label 专用,用于设置标签颜色 + [propName: string]: any; +} +export interface ICategorySearchTagItem { + /** + * 搜索字段,tag的键,用于区分不同的分类,需要唯一 + */ + field: string; + /** + * tag 键的显示值 + */ + label: string; + /** + * 配置项可生产的tag类型 + */ + type?: CategorySearchTagType; + /** + * 配置项所属的分组 + */ + group?: string; + /** + * tag 值的选择项数据 + */ + options?: Array; + /** + * 用于显示的 tag 值的键值,如未设置默认取label + */ + filterKey?: string | 'label'; + /** + * 用于显示的label类型中色值的键值,如未设置默认取color + */ + colorKey?: string | 'color'; + /** + * 已选中值 + */ + value?: { + label?: string; + value?: string | ITagOption | Array; + cache?: string | ITagOption | Array; + [propName: string]: any; + }; + /** + * dateRange 类型是否显示时分秒 + */ + showTime?: boolean; + /** + * dateRange 类型默认激活开始或者结束日期 + */ + activeRangeType?: 'start' | 'end'; + /** + * textInput 类型设置最大长度 + */ + maxLength?: number; + /** + * textInput | numberRange 类型设置占位符,numberRange需传入对象分别设置左右 + */ + placeholder?: string | { left: string; right: string }; + /** + * textInput 表单校验规则 + */ + validateRules?: any[]; + /** + * treeSelect 类型是否为多选,并显示已选择列表 + */ + multiple?: boolean; + /** + * treeSelect 类型是否显示搜索框 + */ + searchable?: boolean; + /** + * treeSelect 类型设置搜索框占位符 + */ + searchPlaceholder?: string; + /** + * treeSelect 类型自定义搜索方法,参数为搜索关键字和d-operable-tree组件实例 + */ + searchFn?: (value: string, treeInstance: any) => boolean | Array; + /** + * treeSelect 类型相关配置,请参考treeSelect组件API中同名配置 + */ + treeNodeIdKey?: string; + treeNodeChildrenKey?: string; + treeNodeTitleKey?: string; + disabledKey?: string; + leafOnly?: boolean; + iconParentOpen?: string; + iconParentClose?: string; + iconLeaf?: string; + [propName: string]: any; +} +export interface SearchConfig { + keyword?: boolean; + keywordDescription?: (searchKey: string) => string; + field?: boolean; + fieldDescription?: (label: string) => string; + category?: boolean; + categoryDescription?: string; +} +export interface TextConfig { + keywordName?: string; + createFilter?: string; + filterTitle?: string; + labelConnector?: string; + noCategoriesAvailable?: string; +} +export interface ExtendConfig { + show?: boolean; + clear?: { + show?: boolean; + disabled?: boolean; + }; + save?: { + show?: boolean; + disabled?: boolean; + }; + more?: { + show?: boolean; + disabled?: boolean; + }; +} + +export const categorySearchProps = { + category: { + type: Array as PropType, + default: () => [] + }, + defaultSearchField: { + type: Array as PropType, + default: () => [] + }, + selectedTags: { + type: Array as PropType, + default: () => [] + }, + toggleScrollToTail: { + type: Boolean, + default: false, + }, + searchKey: { + type: String, + default: '' + }, + placeholder: { + type: String, + default: '' + }, + inputReadOnly: { + type: Boolean, + default: false + }, + tagMaxWidth: { + type: Number, + }, + beforeTagChange: { + type: Function as PropType<(tag: ICategorySearchTagItem, searchKey: string, operation: string) => boolean | Promise>, + }, + showSearchCategory: { + type: [Boolean, Object] as PropType, + default: true, + }, + categoryInGroup: { + type: Boolean, + default: false, + }, + groupOrderConfig: { + type: Array as PropType, + default: () => [] + }, + filterNameRules: { + type: Array as PropType[]>, + }, + textConfig: { + type: Object as PropType, + default: () => ({ + keywordName: '', + createFilter: '', + filterTitle: '', + labelConnector: '|', + noCategoriesAvailable: '' + }), + }, + extendConfig: { + type: Object as PropType, + }, + styleType: { + type: String as PropType, + default: 'default' + } +}; +export type CategorySearchProps = ExtractPropTypes; + +export interface CategorySearchInjection { + rootCtx: SetupContext; + rootRef: Ref; + id: Ref; + innerTextConfig: Ref; + tagMaxWidth: Ref | undefined; + inputReadOnly: Ref; + placeholder: Ref; + innerSearchKey: Ref; + innerSelectedTags: Ref; + isHover: Ref; + isFocus: Ref; + enterSearch: Ref; + showNoDataTips: Ref; + showSearchCategory: Ref; + showSearchConfig: Ref; + categoryDisplay: Ref; + searchField: Ref; + currentSearchCategory: Ref; + ComponentMap: Record; + currentSelectTag: Ref; + filterNameRules: Ref[] | undefined> | undefined; + joinLabelTypes: string[]; + chooseItem: (tag: ICategorySearchTagItem, chooseItem: ITagOption) => void; + onSearchKeyTagClick: () => void; + clearFilter: (e: Event) => void; + onCategoryItemClick: (item: ICategorySearchTagItem) => void; + removeTag: (tag: ICategorySearchTagItem, event?: Event) => void; + chooseItems: (tag: ICategorySearchTagItem) => void; + getTextInputValue: (tag: ICategorySearchTagItem, inputValue: string) => void; + getNumberRangeValue: (tag: ICategorySearchTagItem, rangeValue: number[]) => void; + createFilterFn: (filterName: string) => void; + searchKeyChangeEvent: (e: Event) => void; + searchInputValue: (event: Event) => void; + searchCategory: (item: ICategorySearchTagItem) => void; + showCurrentSearchCategory: (tag: ICategorySearchTagItem) => void; + onInputBackspace: () => void; + onInputToggle: () => void; +} +export const categorySearchInjectionKey: InjectionKey = Symbol('d-category-search'); + +export const categorySearchDropdownProps = { + item: { + type: Object as PropType, + default: () => ({}) + }, + isJoinLabelType: { + type: Boolean, + default: false + }, +}; +export type CategorySearchDropdownProps = ExtractPropTypes; + +export const categorySearchTagProps = { + item: { + type: Object as PropType, + default: () => ({}) + }, + isJoinLabelType: { + type: Boolean, + default: false + }, +}; +export type CategorySearchTagProps = ExtractPropTypes; + +// radio | checkbox | label | textInput 类型,弹出层组件接收的参数 +export const typeMenuProps = { + tag: { + type: Object as PropType, + default: () => ({}) + } +} +export type TypeMenuProps = ExtractPropTypes; + +// clear | save | more 扩展图标组件接收的参数 +export const extendIconProps = { + disabled: { + type: Boolean, + default: false + } +} +export type ExtendIconProps = ExtractPropTypes; \ No newline at end of file diff --git a/packages/devui-vue/devui/category-search/src/category-search.scss b/packages/devui-vue/devui/category-search/src/category-search.scss new file mode 100644 index 0000000000..c5e9c16daf --- /dev/null +++ b/packages/devui-vue/devui/category-search/src/category-search.scss @@ -0,0 +1,417 @@ +@import '@devui/theme/styles-var/devui-var.scss'; + +@mixin tag-item() { + .#{$devui-prefix}-tag>.#{$devui-prefix}-tag__item { + display: block !important; + + .dp-category-search-multi-tag>span, + &>span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: inline-block; + vertical-align: middle; + margin: 0 4px; + } + } +} + +.dp-category-search-container { + position: relative; + border-radius: $devui-border-radius; + width: 100%; + height: 32px; + padding: 0 8px; + display: flex; + align-items: center; + background: transparent; + transition: border $devui-animation-duration-slow $devui-animation-ease-in-out-smooth; + background-color: $devui-base-bg; + border: 1px solid $devui-line; + + ul, + li { + margin: 0; + padding: 0; + list-style: none; + } + + &.container-hover { + &>.dp-category-search-icon svg p path { + fill: $devui-icon-fill-hover; + } + } + + &.dp-gray-style { + background-color: $devui-gray-form-control-bg; + border-color: transparent; + transition: all $devui-animation-duration-slow $devui-animation-ease-in-out-smooth; + + &:hover { + background-color: $devui-gray-form-control-hover-bg; + } + + &:focus-within { + background-color: $devui-base-bg; + border-color: $devui-brand; + } + + .dp-category-search-input input.dp-category-search-toggle { + background-color: transparent !important; + } + } + + .dp-category-search-toggle { + color: $devui-text; + } + + .dp-category-search-line-container { + width: 100%; + height: 32px; + overflow: hidden; + + &:hover { + overflow-x: auto; + overflow-y: overlay; + } + + .dp-category-search-line { + display: flex; + flex-flow: row nowrap; + justify-content: flex-start; + align-items: flex-start; + width: 100%; + height: 30px; + position: relative; + + &>li { + display: flex; + align-items: center; + flex-grow: 0; + white-space: nowrap; + height: 100%; + } + + .dp-category-search-input { + display: flex; + justify-content: flex-start; + flex-grow: 1; + align-items: center; + min-width: 160px; + padding-right: 20px; + + input { + min-width: 240px; + width: 100%; + height: 32px; + font-size: $devui-font-size-sm; + } + + .dp-category-search-toggle { + padding-left: 0; + } + + .dp-category-search-keyword-in-category { + overflow: visible; + } + } + } + } + + input { + border: none; + background: transparent; + outline: none; + + &::placeholder { + color: $devui-placeholder; + } + + &:focus { + outline: none; + } + } + + .dp-input-container { + display: flex; + flex: 1; + } + + .dp-category-search-extended-container { + display: flex; + flex-wrap: nowrap; + width: fit-content; + height: 16px; + margin: 8px 0 8px; + border-left: 1px solid $devui-line; + padding-left: 8px; + } +} + +.dp-category-search-icon { + display: flex; + padding-right: 8px; + align-items: center; + height: 16px; + cursor: pointer; + + svg g path { + outline: none; + fill: $devui-icon-fill; + transition: all $devui-animation-ease-in-out-smooth $devui-animation-duration-slow; + } + + &.disabled { + cursor: not-allowed; + + svg g path { + fill: $devui-disabled-text; + } + } + + &:not(.disabled):hover svg g path { + fill: $devui-icon-fill-hover; + } +} + +li.dp-tag-item { + display: inline-block; + margin-right: 4px; + @include tag-item(); + + &:first-child { + margin-left: 4px; + } + + &:focus { + outline: none; + } + + .dp-color-block-split-line { + color: $devui-aide-text; + } + + .dp-color-block-sm { + width: 8px; + height: 8px; + border-radius: $devui-border-radius; + margin-right: 4px; + display: inline-block; + position: relative; + top: -1px; + vertical-align: middle; + } +} + +.dp-category-search-dropdown { + min-width: 200px; + overflow-x: auto; + white-space: nowrap; + + ul, + li { + padding: 0; + margin: 0; + list-style: none; + } +} + +.dp-dropdown-menu-template { + max-width: 300px; + max-height: 352px; + padding: 12px !important; + overflow: auto; + + &.dp-category-search-keyword-in-category { + padding: 0 !important; + } + + .dp-dropdown-item { + display: block; + width: 100%; + max-height: 36px; + line-height: 20px; + padding: 8px 12px !important; + border-radius: $devui-border-radius; + border: 0; + box-sizing: border-box; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + transition: color $devui-animation-duration-fast $devui-animation-ease-in-out-smooth, + background-color $devui-animation-duration-fast $devui-animation-ease-in-out-smooth; + cursor: pointer; + + &:not(:first-child) { + margin-top: 4px; + } + + &:hover { + color: $devui-list-item-hover-text; + background-color: $devui-list-item-hover-bg; + } + + &.active { + color: $devui-list-item-active-text; + background-color: $devui-list-item-active-bg; + } + + .icon-search { + margin-right: 4px; + } + } + + .dp-dividing-line { + height: 1px; + width: 100%; + background-color: $devui-dividing-line; + margin-top: 4px; + } + + .dp-dropdown-menu-tip { + cursor: text; + padding: 12px 12px 0; + pointer-events: none; + color: $devui-aide-text; + } + + .dp-color-block { + width: 12px; + height: 12px; + margin-right: 8px; + border-radius: $devui-border-radius; + position: relative; + top: -1px; + vertical-align: middle; + display: inline-block; + } +} + +.dp-save-panel { + width: 400px; + height: auto; + + .dp-save-panel-title { + display: flex; + justify-content: space-between; + height: 48px; + line-height: 48px; + border-bottom: 1px solid $devui-dividing-line; + padding: 0 20px; + font-size: 14px; + font-weight: bold; + color: $devui-font-size-page-title; + } + + .dp-save-filter-name { + padding: 16px 20px; + + .#{$devui-prefix}-form__item--vertical { + margin-bottom: 0; + } + } + + .dp-save-panel-operation-area { + padding-bottom: 12px; + display: flex; + justify-content: center; + + .#{$devui-prefix}-button:not(:first-child) { + margin-left: 16px; + } + } +} + +.dp-dropdown-menu-fix { + min-width: 200px; + margin-left: 0; + overflow-y: auto; + white-space: nowrap; + + &.max-height { + max-height: 405px; + } + + .#{$devui-prefix}-form { + .#{$devui-prefix}-form__item--horizontal { + margin: 16px 8px; + } + + .#{$devui-prefix}-form__label { + display: none; + } + + .#{$devui-prefix}-form__control--horizontal { + margin-left: 0; + } + } + + .#{$devui-prefix}-input-number { + width: 70px; + } +} + +.dp-selected-tags-list { + ul { + display: flex; + flex-flow: row wrap; + align-items: center; + justify-content: flex-start; + max-height: 390px; + overflow: auto; + + li.dp-tag-item { + display: flex; + flex-grow: 0; + flex-flow: row wrap; + margin: 2px 4px 2px 0; + + &:last-child { + margin-right: 0; + } + } + } +} + +.dp-dropdown-operation-area { + border-top: 1px solid $devui-dividing-line; + padding-top: 8px; + margin: 8px 0; + display: flex; + justify-content: center; + + &>.#{$devui-prefix}-button:first-child { + margin-right: 8px; + } +} + +.dp-input-number-operation-area { + padding: 16px 16px 8px; + display: flex; + justify-content: space-evenly; +} + +.dp-no-data-text { + font-size: $devui-font-size; + color: $devui-text; + padding: 8px 0; + text-align: center; +} + +.dp-scrollbar::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.dp-scrollbar::-webkit-scrollbar-track { + background-color: transparent; +} + +.dp-scrollbar::-webkit-scrollbar-thumb { + border-radius: 8px; + background-color: $devui-line; +} + +.dp-scrollbar::-webkit-scrollbar-thumb:hover { + background-color: $devui-placeholder; +} \ No newline at end of file diff --git a/packages/devui-vue/devui/category-search/src/category-search.tsx b/packages/devui-vue/devui/category-search/src/category-search.tsx new file mode 100644 index 0000000000..6f85d396d5 --- /dev/null +++ b/packages/devui-vue/devui/category-search/src/category-search.tsx @@ -0,0 +1,62 @@ +import { defineComponent } from 'vue'; +import type { SetupContext } from 'vue'; +import { SearchIcon } from './components/category-search-icons'; +import CategorySearchTagDropdown from './components/category-search-tag-dropdown'; +import CategorySearchInput from './components/category-search-input'; +import CategorySearchClear from './components/category-search-clear'; +import CategorySearchSave from './components/category-search-save'; +import CategorySearchMore from './components/category-search-more'; +import { categorySearchProps } from './category-search-types'; +import type { CategorySearchProps } from './category-search-types'; +import { useCategorySearch } from './composables/use-category-search'; +import './category-search.scss'; + +export default defineComponent({ + name: 'DCategorySearch', + props: categorySearchProps, + emits: ['search', 'selectedTagsChange', 'createFilter', 'clearAll', 'searchKeyChange'], + setup(props: CategorySearchProps, ctx: SetupContext) { + const { + rootRef, + scrollBarRef, + inputRef, + isHover, + containerClasses, + innerSelectedTags, + joinLabelTypes, + showExtendedConfig, + operationConfig, + onSearch, + } = useCategorySearch(props, ctx); + + return () => ( +
(isHover.value = true)} + onMouseleave={() => (isHover.value = false)}> +
+ +
+
+
    + {innerSelectedTags.value.map((item) => ( + + ))} + +
+
+ {showExtendedConfig.value && ( +
+ {operationConfig.clear?.show && ( + <>{ctx.slots.clear?.() ?? } + )} + {operationConfig.save?.show && <>{ctx.slots.save?.() ?? }} + {operationConfig.more?.show && <>{ctx.slots.more?.() ?? }} + {ctx.slots.operation?.()} +
+ )} +
+ ); + }, +}); diff --git a/packages/devui-vue/devui/category-search/src/components/category-search-clear.tsx b/packages/devui-vue/devui/category-search/src/components/category-search-clear.tsx new file mode 100644 index 0000000000..4c566afa0b --- /dev/null +++ b/packages/devui-vue/devui/category-search/src/components/category-search-clear.tsx @@ -0,0 +1,24 @@ +import { defineComponent, toRefs, inject } from 'vue'; +import { ClearIcon } from './category-search-icons'; +import { extendIconProps, categorySearchInjectionKey } from '../category-search-types'; +import type { ExtendIconProps, CategorySearchInjection } from '../category-search-types'; + +export default defineComponent({ + name: 'DCategorySearchClear', + props: extendIconProps, + setup(props: ExtendIconProps) { + const { disabled } = toRefs(props); + const { clearFilter } = inject(categorySearchInjectionKey) as CategorySearchInjection; + const onClick = (e: Event) => { + if (!disabled.value) { + clearFilter(e); + } + }; + + return () => ( +
+ +
+ ); + }, +}); \ No newline at end of file diff --git a/packages/devui-vue/devui/category-search/src/components/category-search-icons.tsx b/packages/devui-vue/devui/category-search/src/components/category-search-icons.tsx new file mode 100644 index 0000000000..58106da1b0 --- /dev/null +++ b/packages/devui-vue/devui/category-search/src/components/category-search-icons.tsx @@ -0,0 +1,67 @@ +export function SearchIcon(): JSX.Element { + return ( + + + + + + ); +} + +export function ClearIcon(): JSX.Element { + return ( + + 清空 + + + + + ); +} + +export function SaveIcon(props: Record): JSX.Element { + return ( + + {props.textConfig.createFilter} + + + + + ); +} + +export function MoreIcon(): JSX.Element { + return ( + + 查看全部过滤条件 + + + + + ); +} \ No newline at end of file diff --git a/packages/devui-vue/devui/category-search/src/components/category-search-input.tsx b/packages/devui-vue/devui/category-search/src/components/category-search-input.tsx new file mode 100644 index 0000000000..930f5c83af --- /dev/null +++ b/packages/devui-vue/devui/category-search/src/components/category-search-input.tsx @@ -0,0 +1,181 @@ +import { defineComponent, inject, h } from 'vue'; +import type { SetupContext } from 'vue'; +import { Dropdown } from '../../../dropdown'; +import { categorySearchInjectionKey } from '../category-search-types'; +import type { CategorySearchInjection, ICategorySearchTagItem } from '../category-search-types'; +import { useCategorySearchInput } from '../composables/use-category-search-input'; + +export default defineComponent({ + name: 'DCategorySearchInput', + setup(_, ctx: SetupContext) { + const { + rootCtx, + id, + inputReadOnly, + placeholder, + innerSearchKey, + isHover, + isFocus, + enterSearch, + showNoDataTips, + showSearchCategory, + categoryDisplay, + innerTextConfig, + showSearchConfig, + searchField, + currentSearchCategory, + ComponentMap, + currentSelectTag, + onCategoryItemClick, + searchKeyChangeEvent, + searchInputValue, + searchCategory, + showCurrentSearchCategory, + onInputBackspace, + onInputToggle + } = inject(categorySearchInjectionKey) as CategorySearchInjection; + const { isVisible, inputRef, onInputClick, onDropdownClose, closeMenu } = useCategorySearchInput(ctx); + const checkType = (tag: ICategorySearchTagItem | undefined) => { + return tag && tag.type === 'radio' ? 'all' : 'blank'; + }; + const onToggle = (status: boolean) => { + isVisible.value = status; + onInputToggle(); + }; + const onInputKeydown = (e: KeyboardEvent) => { + if (e.key === 'Backspace') { + onInputBackspace(); + } + if (e.key === 'Enter') { + searchInputValue(e); + closeMenu(); + } + }; + const onKeydownDescriptionClick = (e: Event) => { + searchInputValue(e); + closeMenu(); + }; + const onFieldDescriptionClick = (item: ICategorySearchTagItem) => { + searchCategory(item); + closeMenu(); + }; + const onCategorySuggestionClick = (item: ICategorySearchTagItem) => { + showCurrentSearchCategory(item); + closeMenu(); + }; + + return () => ( +
+
+ + {{ + default: () => ( + { + isHover.value = false; + isFocus.value = true; + }} + onBlur={() => { + isFocus.value = false; + }}> + ), + menu: + !enterSearch.value || (enterSearch.value && showSearchCategory.value) + ? () => ( + <> + {!currentSelectTag.value && ( +
    + {!enterSearch.value ? ( + <> + {categoryDisplay.value.map((item) => ( + <> + {item.groupLength && ( +
  • + {rootCtx.slots.groupName?.({ tag: item }) || {item.groupName}} +
  • + )} + {item.groupLength === undefined && !item.isSelected && ( +
  • onCategoryItemClick(item)}> + {item.label} +
  • + )} + + ))} + {showNoDataTips.value && ( +
    {innerTextConfig.value.noCategoriesAvailable || '没有筛选条件'}
    + )} + + ) : ( + <> + {showSearchConfig.value.keyword && ( +
  • + + {showSearchConfig.value.keywordDescription?.(innerSearchKey.value)} +
  • + )} + {showSearchConfig.value.field && + searchField.value.map((item) => ( +
  • onFieldDescriptionClick(item)}> + + {showSearchConfig.value.fieldDescription?.(item.label)} +
  • + ))} + {(showSearchConfig.value.keyword || showSearchConfig.value.field) && + showSearchConfig.value.category && + Boolean(currentSearchCategory.value.length) &&
    } + {showSearchConfig.value.category && Boolean(currentSearchCategory.value.length) && ( + <> +
    + {showSearchConfig.value.categoryDescription} +
    +
      + {currentSearchCategory.value.map((item) => ( +
    • onCategorySuggestionClick(item)}> + {item.label} +
    • + ))} +
    + + )} + + )} +
+ )} + {!enterSearch.value && + currentSelectTag.value && + (rootCtx.slots[`${currentSelectTag.value.field}Menu`]?.({ + tagOption: currentSelectTag.value, + close: onDropdownClose, + }) || + h(ComponentMap[currentSelectTag.value.type!], { tag: currentSelectTag.value, onClose: onDropdownClose }))} + + ) + : null, + }} +
+
+
+ ); + }, +}); \ No newline at end of file diff --git a/packages/devui-vue/devui/category-search/src/components/category-search-more.tsx b/packages/devui-vue/devui/category-search/src/components/category-search-more.tsx new file mode 100644 index 0000000000..a8bcb2ab5d --- /dev/null +++ b/packages/devui-vue/devui/category-search/src/components/category-search-more.tsx @@ -0,0 +1,40 @@ +import { defineComponent, toRefs, Teleport } from 'vue'; +import { FlexibleOverlay } from '../../../overlay'; +import { MoreIcon } from './category-search-icons'; +import CategorySearchTag from './category-search-tag'; +import { extendIconProps } from '../category-search-types'; +import type { ExtendIconProps } from '../category-search-types'; +import { useCategorySearchMore } from '../composables/use-category-search-icons'; + +export default defineComponent({ + name: 'DCategorySearchMore', + props: extendIconProps, + setup(props: ExtendIconProps) { + const { disabled } = toRefs(props); + const { isVisible, rootRef, iconRef, overlayRef, innerSelectedTags, joinLabelTypes } = useCategorySearchMore(); + + return () => ( +
+ (isVisible.value = !isVisible.value)} /> + + (isVisible.value = false)}> +
    + {innerSelectedTags.value.map((item) => ( +
  • + +
  • + ))} +
+
+
+
+ ); + }, +}); \ No newline at end of file diff --git a/packages/devui-vue/devui/category-search/src/components/category-search-save.tsx b/packages/devui-vue/devui/category-search/src/components/category-search-save.tsx new file mode 100644 index 0000000000..8868e70d4b --- /dev/null +++ b/packages/devui-vue/devui/category-search/src/components/category-search-save.tsx @@ -0,0 +1,55 @@ +import { defineComponent, toRefs, inject } from 'vue'; +import { Dropdown } from '../../../dropdown'; +import { SaveIcon } from './category-search-icons'; +import { extendIconProps, categorySearchInjectionKey } from '../category-search-types'; +import type { CategorySearchInjection, ExtendIconProps } from '../category-search-types'; +import { useCategorySearchSave } from '../composables/use-category-search-icons'; + +export default defineComponent({ + name: 'DCategorySearchSave', + props: extendIconProps, + setup(props: ExtendIconProps) { + const { disabled } = toRefs(props); + const { innerTextConfig, filterNameRules, createFilterFn } = inject(categorySearchInjectionKey) as CategorySearchInjection; + const { isVisible, formRef, formData, inputRef, onConfirm, onToggle } = useCategorySearchSave(createFilterFn); + + return () => ( +
+ + {{ + default: () => (isVisible.value = !isVisible.value)} />, + menu: () => ( + <> +
+ {innerTextConfig.value.createFilter} +
+
+ + + + + +
+
+ + 确定 + + (isVisible.value = false)}> + 取消 + +
+ + ), + }} +
+
+ ); + }, +}); \ No newline at end of file diff --git a/packages/devui-vue/devui/category-search/src/components/category-search-tag-dropdown.tsx b/packages/devui-vue/devui/category-search/src/components/category-search-tag-dropdown.tsx new file mode 100644 index 0000000000..07149f1f9d --- /dev/null +++ b/packages/devui-vue/devui/category-search/src/components/category-search-tag-dropdown.tsx @@ -0,0 +1,54 @@ +import { defineComponent, toRefs, inject, h, ref } from 'vue'; +import { Dropdown } from '../../../dropdown'; +import CategorySearchTag from './category-search-tag'; +import { categorySearchDropdownProps, categorySearchInjectionKey } from '../category-search-types'; +import type { CategorySearchDropdownProps, CategorySearchInjection, ICategorySearchTagItem } from '../category-search-types'; + +export default defineComponent({ + name: 'DCategorySearchDropdown', + props: categorySearchDropdownProps, + setup(props: CategorySearchDropdownProps) { + const { item, isJoinLabelType } = toRefs(props); + const { rootCtx, ComponentMap, onSearchKeyTagClick } = inject(categorySearchInjectionKey) as CategorySearchInjection; + const isVisible = ref(false); + const checkType = (tag: ICategorySearchTagItem | undefined) => { + return tag && tag.type === 'radio' ? 'all' : 'blank'; + } + const onTagClick = () => { + isVisible.value = !isVisible.value; + }; + const onToggle = (status: boolean) => { + isVisible.value = status; + }; + const onDropdownClose = () => { + isVisible.value = false; + }; + + return () => + item.value.type !== 'keyword' ? ( + + {{ + default: () => ( +
  • + +
  • + ), + menu: () => + rootCtx.slots[`${item.value.field}Menu`]?.({ tagOption: item.value, close: onDropdownClose }) || + h(ComponentMap[item.value.type!], { tag: item.value, onClose: onDropdownClose }) + }} +
    + ) : ( +
  • + +
  • + ); + }, +}); \ No newline at end of file diff --git a/packages/devui-vue/devui/category-search/src/components/category-search-tag.tsx b/packages/devui-vue/devui/category-search/src/components/category-search-tag.tsx new file mode 100644 index 0000000000..9c95a0b5f9 --- /dev/null +++ b/packages/devui-vue/devui/category-search/src/components/category-search-tag.tsx @@ -0,0 +1,43 @@ +import { defineComponent, toRefs, inject } from 'vue'; +import { Tag } from '../../../tag'; +import { categorySearchTagProps, categorySearchInjectionKey } from '../category-search-types'; +import type { CategorySearchTagProps, CategorySearchInjection } from '../category-search-types'; + +export default defineComponent({ + name: 'DCategorySearchTag', + props: categorySearchTagProps, + setup(props: CategorySearchTagProps) { + const { item, isJoinLabelType } = toRefs(props); + const { rootCtx, tagMaxWidth, innerTextConfig, removeTag } = inject(categorySearchInjectionKey) as CategorySearchInjection; + + return () => ( + removeTag(item.value, e)}> + {rootCtx.slots[`${item.value.field}Tag`] ? ( + rootCtx.slots[`${item.value.field}Tag`]!({ tag: item.value }) + ) : isJoinLabelType.value ? ( + <> + {item.value.label} + + {Array.isArray(item.value.value?.cache) && + item.value.value?.cache?.map((tag: any, index: number) => ( + <> + {index > 0 && {innerTextConfig.value.labelConnector || '|'}} + {item.value.type === 'label' && ( + + )} + {tag[item.value.filterKey || 'label'] || ''} + + ))} + + + ) : ( + `${item.value.label}: ${item.value.value?.[item.value.filterKey || 'label'] || ''}` + )} + + ); + }, +}); \ No newline at end of file diff --git a/packages/devui-vue/devui/category-search/src/components/checkbox-menu.tsx b/packages/devui-vue/devui/category-search/src/components/checkbox-menu.tsx new file mode 100644 index 0000000000..0521be6c36 --- /dev/null +++ b/packages/devui-vue/devui/category-search/src/components/checkbox-menu.tsx @@ -0,0 +1,42 @@ +import { defineComponent, toRefs, inject } from 'vue'; +import type { SetupContext } from 'vue'; +import { CheckboxGroup } from '../../../checkbox'; +import { Button } from '../../../button'; +import { typeMenuProps, categorySearchInjectionKey } from '../category-search-types'; +import type { TypeMenuProps, CategorySearchInjection } from '../category-search-types'; + +export default defineComponent({ + name: 'DCategorySearchCheckboxMenu', + props: typeMenuProps, + emits: ['close'], + setup(props: TypeMenuProps, ctx: SetupContext) { + const { tag } = toRefs(props); + const { chooseItems } = inject(categorySearchInjectionKey) as CategorySearchInjection; + const onConfirmClick = () => { + chooseItems(tag.value); + ctx.emit('close'); + }; + const onCancelClick = () => { + ctx.emit('close'); + }; + + return () => + tag.value.options?.length ? ( + <> +
    + +
    +
    + + +
    + + ) : ( +
    暂无数据
    + ); + }, +}); \ No newline at end of file diff --git a/packages/devui-vue/devui/category-search/src/components/label-menu.tsx b/packages/devui-vue/devui/category-search/src/components/label-menu.tsx new file mode 100644 index 0000000000..198bfd18e2 --- /dev/null +++ b/packages/devui-vue/devui/category-search/src/components/label-menu.tsx @@ -0,0 +1,62 @@ +import { defineComponent, toRefs, inject } from 'vue'; +import type { SetupContext } from 'vue'; +import { CheckboxGroup, Checkbox } from '../../../checkbox'; +import { Button } from '../../../button'; +import { typeMenuProps, categorySearchInjectionKey } from '../category-search-types'; +import type { TypeMenuProps, CategorySearchInjection } from '../category-search-types'; + +export default defineComponent({ + name: 'DCategorySearchLabelMenu', + props: typeMenuProps, + emits: ['close'], + setup(props: TypeMenuProps, ctx: SetupContext) { + const { tag } = toRefs(props); + const { chooseItems } = inject(categorySearchInjectionKey) as CategorySearchInjection; + const onConfirmClick = () => { + chooseItems(tag.value); + ctx.emit('close'); + }; + const onCancelClick = () => { + ctx.emit('close'); + }; + // label 拆分名称和颜色用于下拉选项显示 + const splitLabel = (key: string, value: any) => { + // 初始化选中类型生成标签时,value为label的对象,不需要对值进行操作 + if (typeof value !== 'string') { + return; + } + const res = value && value.split('_'); + const obj = res && { label: res[0], color: res[1] }; + return obj && obj[key]; + }; + + return () => + tag.value.options?.length ? ( + <> +
    + + {tag.value.options.map((item) => ( + + + {splitLabel('label', item.$label)} + + ))} + +
    +
    + + +
    + + ) : ( +
    暂无数据
    + ); + }, +}); \ No newline at end of file diff --git a/packages/devui-vue/devui/category-search/src/components/number-range-menu.tsx b/packages/devui-vue/devui/category-search/src/components/number-range-menu.tsx new file mode 100644 index 0000000000..cc9f47cc73 --- /dev/null +++ b/packages/devui-vue/devui/category-search/src/components/number-range-menu.tsx @@ -0,0 +1,54 @@ +import { defineComponent, toRefs, inject, ref } from 'vue'; +import type { SetupContext } from 'vue'; +import { InputNumber } from '../../../input-number'; +import { Button } from '../../../button'; +import { typeMenuProps, categorySearchInjectionKey } from '../category-search-types'; +import type { TypeMenuProps, CategorySearchInjection } from '../category-search-types'; + +export default defineComponent({ + name: 'DCategorySearchNumberRange', + props: typeMenuProps, + emits: ['close'], + setup(props: TypeMenuProps, ctx: SetupContext) { + const { tag } = toRefs(props); + const { getNumberRangeValue } = inject(categorySearchInjectionKey) as CategorySearchInjection; + const num = ref(tag.value.value!.value?.length ? [...tag.value.value!.value] : [0, 0]); + const onConfirmClick = () => { + getNumberRangeValue(tag.value, num.value); + ctx.emit('close'); + }; + const onCancelClick = () => { + ctx.emit('close'); + }; + + return () => ( + <> +
    + + - + +
    +
    + + +
    + + ); + }, +}); \ No newline at end of file diff --git a/packages/devui-vue/devui/category-search/src/components/radio-menu.tsx b/packages/devui-vue/devui/category-search/src/components/radio-menu.tsx new file mode 100644 index 0000000000..f83cd45280 --- /dev/null +++ b/packages/devui-vue/devui/category-search/src/components/radio-menu.tsx @@ -0,0 +1,29 @@ +import { defineComponent, toRefs, computed, inject } from 'vue'; +import { typeMenuProps, categorySearchInjectionKey } from '../category-search-types'; +import type { TypeMenuProps, CategorySearchInjection } from '../category-search-types'; + +export default defineComponent({ + name: 'DCategorySearchRadioMenu', + props: typeMenuProps, + setup(props: TypeMenuProps) { + const { tag } = toRefs(props); + const { chooseItem } = inject(categorySearchInjectionKey) as CategorySearchInjection; + const key = computed(() => tag.value.filterKey || 'label'); + + return () => + tag.value.options?.length ? ( +
      + {tag.value.options.map((item) => ( +
    • chooseItem(tag.value, item)}> + {item[key.value]} +
    • + ))} +
    + ) : ( +
    暂无数据
    + ); + }, +}); \ No newline at end of file diff --git a/packages/devui-vue/devui/category-search/src/components/text-input-menu.tsx b/packages/devui-vue/devui/category-search/src/components/text-input-menu.tsx new file mode 100644 index 0000000000..ac83d0fabd --- /dev/null +++ b/packages/devui-vue/devui/category-search/src/components/text-input-menu.tsx @@ -0,0 +1,45 @@ +import { defineComponent, toRefs, inject, reactive } from 'vue'; +import type { SetupContext } from 'vue'; +import { Button } from '../../../button'; +import { typeMenuProps, categorySearchInjectionKey } from '../category-search-types'; +import type { TypeMenuProps, CategorySearchInjection } from '../category-search-types'; + +export default defineComponent({ + name: 'DCategorySearchTextInput', + props: typeMenuProps, + emits: ['close'], + setup(props: TypeMenuProps, ctx: SetupContext) { + const { tag } = toRefs(props); + const { getTextInputValue } = inject(categorySearchInjectionKey) as CategorySearchInjection; + const formData = reactive({ + text: tag.value.value!.value, + }) + const onConfirmClick = () => { + getTextInputValue(tag.value, formData.text as string); + ctx.emit('close'); + }; + const onCancelClick = () => { + ctx.emit('close'); + }; + + return () => ( + + + + +
    + + +
    +
    + ); + }, +}); \ No newline at end of file diff --git a/packages/devui-vue/devui/category-search/src/composables/use-category-search-icons.ts b/packages/devui-vue/devui/category-search/src/composables/use-category-search-icons.ts new file mode 100644 index 0000000000..599dd7e38f --- /dev/null +++ b/packages/devui-vue/devui/category-search/src/composables/use-category-search-icons.ts @@ -0,0 +1,54 @@ +import { reactive, ref, inject } from 'vue'; +import { onClickOutside } from '@vueuse/core'; +import { categorySearchInjectionKey } from '../category-search-types'; +import type { CategorySearchInjection } from '../category-search-types'; +import { DELAY } from '../category-search-const'; + +export function useCategorySearchSave(createFilterFn: (val: string) => void) { + const isVisible = ref(false); + const formRef = ref(); + const inputRef = ref(); + const formData = reactive({ + filterName: '' + }); + + const onConfirm = () => { + formRef.value.validate((isValid: boolean) => { + if (isValid) { + createFilterFn(formData.filterName); + isVisible.value = false; + setTimeout(() => { + formData.filterName = ''; + }, DELAY); + } + }); + }; + const onToggle = (status: boolean) => { + isVisible.value = status; + if (status) { + formData.filterName = ''; + setTimeout(() => { + inputRef.value.focus(); + }); + } + }; + + return { isVisible, formRef, formData, inputRef, onConfirm, onToggle }; +} + +export function useCategorySearchMore() { + const { rootRef, innerSelectedTags, joinLabelTypes } = inject(categorySearchInjectionKey) as CategorySearchInjection; + const isVisible = ref(false); + const iconRef = ref(); + const overlayRef = ref(); + + onClickOutside( + overlayRef, + () => { + isVisible.value = false; + }, + { ignore: [iconRef] } + ); + + return { isVisible, rootRef, iconRef, overlayRef, innerSelectedTags, joinLabelTypes }; +} \ No newline at end of file diff --git a/packages/devui-vue/devui/category-search/src/composables/use-category-search-input.ts b/packages/devui-vue/devui/category-search/src/composables/use-category-search-input.ts new file mode 100644 index 0000000000..af944d8d44 --- /dev/null +++ b/packages/devui-vue/devui/category-search/src/composables/use-category-search-input.ts @@ -0,0 +1,32 @@ +import { ref } from 'vue'; +import type { SetupContext } from 'vue'; + +export function useCategorySearchInput(ctx: SetupContext) { + const inputRef = ref(); + const isVisible = ref(false); + + const onInputClick = () => { + isVisible.value = !isVisible.value; + }; + + const onDropdownClose = () => { + isVisible.value = false; + }; + + const focus = () => { + inputRef.value?.focus(); + }; + const scrollIntoView = () => { + inputRef.value?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }); + }; + const openMenu = () => { + isVisible.value = true; + }; + const closeMenu = () => { + isVisible.value = false; + }; + + ctx.expose({ focus, scrollIntoView, openMenu, closeMenu }); + + return { isVisible, inputRef, onInputClick, onDropdownClose, closeMenu }; +} \ No newline at end of file diff --git a/packages/devui-vue/devui/category-search/src/composables/use-category-search.ts b/packages/devui-vue/devui/category-search/src/composables/use-category-search.ts new file mode 100644 index 0000000000..3ee68fe250 --- /dev/null +++ b/packages/devui-vue/devui/category-search/src/composables/use-category-search.ts @@ -0,0 +1,731 @@ +import { ref, computed, toRefs, provide, watch, onMounted, reactive } from 'vue'; +import type { Ref, SetupContext } from 'vue'; +import { mergeWith, cloneDeep, merge } from 'lodash-es'; +import RadioMenu from '../components/radio-menu'; +import CheckboxMenu from '../components/checkbox-menu'; +import LabelMenu from '../components/label-menu'; +import TextInputMenu from '../components/text-input-menu'; +import NumberRangeMenu from '../components/number-range-menu'; +import { categorySearchInjectionKey } from '../category-search-types'; +import type { ExtendConfig, CategorySearchProps, ICategorySearchTagItem, SearchConfig, ITagOption, TextConfig } from '../category-search-types'; +import { DELAY, SearchKeyField, DROPDOWN_ANIMATION_TIMEOUT, getSearchMessage, getFindingMessage, COLORS } from '../category-search-const'; + +let ID_SEED = 0; + +export function useCategorySearch(props: CategorySearchProps, ctx: SetupContext) { + const { + category, + tagMaxWidth, + textConfig, + inputReadOnly, + placeholder, + searchKey, + selectedTags, + styleType, + categoryInGroup, + groupOrderConfig, + defaultSearchField, + beforeTagChange, + toggleScrollToTail, + showSearchCategory, + filterNameRules, + extendConfig, + } = toRefs(props); + const innerCategory: Ref = ref([]); + const innerSelectedTags: Ref = ref([]); + const innerTextConfig: Ref = ref({}); + const id = ref(ID_SEED++); + const isHover = ref(false); + const isFocus = ref(false); + const enterSearch = ref(false); + const showNoDataTips = ref(false); + const innerSearchKey = ref(searchKey.value); + const scrollBarRef = ref(); + const rootRef = ref(); + const inputRef = ref(); + const showSearchConfig: Ref = ref({ keyword: true, field: true, category: true }); + const categoryDisplay: Ref = ref([]); + const searchField: Ref = ref([]); + const currentSearchCategory: Ref = ref([]); + const currentSelectTag: Ref = ref(); + const joinLabelTypes = ['checkbox', 'label']; + const valueIsArrayTypes = ['dateRange', 'numberRange', 'treeSelect', 'checkbox', 'label']; + const ComponentMap: Record = { + radio: RadioMenu, + checkbox: CheckboxMenu, + label: LabelMenu, + textInput: TextInputMenu, + numberRange: NumberRangeMenu, + }; + const operationConfig: ExtendConfig = reactive({ + clear: { show: true }, + save: { show: true }, + more: { show: false }, + }); + let scrollToTailFlag = true; // 是否在更新标签内容后滚动至输入框的开关 + let isSearchCategory = false; + let categoryOrder: any[] = []; + let categoryDictionary: Record = {}; + let searchKeyCache = ''; + let blurTimer: any; // 失焦关闭下拉延时器,失焦后立刻展开下拉需清除该延时 + + const containerClasses = computed(() => ({ + 'dp-category-search-container': true, + [`dp-category-search-id-${id.value}`]: true, + 'container-hover': isHover.value && !isFocus.value, + 'dp-gray-style': styleType.value === 'gray', + })); + + const showExtendedConfig = computed(() => operationConfig.show ?? Boolean(innerSelectedTags.value.length || innerSearchKey.value)); + + const removeTag = (tag: ICategorySearchTagItem, event?: Event) => { + canChange(tag, 'delete').then((val) => { + if (!val) { + if (beforeTagChange?.value && event) { + event.stopPropagation(); + } + return; + } + tag = resetValue(tag); + innerSelectedTags.value = innerSelectedTags.value.filter((item) => item.field !== tag.field); + const result = getSelectedTagsExceptKeyword(); + if (tag.type === 'keyword') { + innerSearchKey.value = innerSearchKey.value === searchKeyCache ? '' : innerSearchKey.value; + searchKeyCache = ''; + enterSearch.value = innerSearchKey.value !== ''; + ctx.emit('search', { selectedTags: result, searchKey: innerSearchKey.value }); + } else { + resolveCategoryDisplay(tag, 'add'); + ctx.emit('selectedTagsChange', { selectedTags: result, currentChangeTag: tag, operation: 'delete' }); + } + currentSelectTag.value = undefined; + }); + }; + + const onSearch = () => { + ctx.emit('search', { selectedTags: getSelectedTagsExceptKeyword(), searchKey: setSearchKeyTag() }); + isFocus.value = true; + }; + + // radio 单选 处理选中项方法 + const chooseItem = (tag: ICategorySearchTagItem, chooseItem: ITagOption) => { + afterDropdownClosed(); + const key = tag.filterKey || 'label'; + tag.value = { value: chooseItem, cache: cloneDeep(chooseItem) }; + tag.value[key] = chooseItem[key]; + tag.title = setTitle(tag, 'radio'); + updateSelectedTags(tag); + }; + + // checkbox | label 多选 处理选中项方法 + const chooseItems = (tag: ICategorySearchTagItem) => { + afterDropdownClosed(); + const key = tag.filterKey || 'label'; + if (tag.type === 'label') { + tag.value!.value = tag.value!.value!.map((item) => { + const res = item.split('_'); + return { + $label: item, + [tag.filterKey || 'label']: res[0], + [tag.colorKey || 'color']: res[1], + }; + }); + } + const result = getItemValue(tag.value!.value, key); + if (result) { + tag.title = setTitle(tag, 'checkbox', result); + tag.value![key] = result; + tag.value!.cache = cloneDeep(tag.value!.value); + updateSelectedTags(tag); + } else { + removeTag(tag); + } + }; + + // textInput 文本输入框 处理选中项方法 + const getTextInputValue = (tag: ICategorySearchTagItem, inputValue: string) => { + afterDropdownClosed(); + tag.value![tag.filterKey || 'label'] = tag.value!.cache = tag.value!.value = inputValue; + tag.title = setTitle(tag, 'textInput'); + updateSelectedTags(tag); + }; + + // numberRange 数字范围 处理选中项方法 + const getNumberRangeValue = (tag: ICategorySearchTagItem, rangeValue: number[]) => { + afterDropdownClosed(); + const startNum = rangeValue[0] || 0; + const endNum = rangeValue[1] || 0; + tag.value!.value = [startNum, endNum]; + tag.value!.cache = [startNum, endNum]; + tag.value![tag.filterKey || 'label'] = `${startNum} - ${endNum}`; + tag.title = setTitle(tag, 'numberRange'); + updateSelectedTags(tag); + }; + + const onSearchKeyTagClick = () => { + innerSearchKey.value = searchKeyCache; + inputRef.value.focus(); + ctx.emit('searchKeyChange', innerSearchKey.value); + }; + + // 清空 + const clearFilter = (event: Event) => { + if (innerSelectedTags.value.length) { + innerSelectedTags.value.forEach((item) => resetValue(item)); + innerSelectedTags.value = []; + } + if (innerSearchKey.value || searchKeyCache) { + innerSearchKey.value = ''; + searchKeyCache = ''; + } + if (currentSelectTag.value) { + currentSelectTag.value = undefined; + } + ctx.emit('selectedTagsChange', { selectedTags: [], currentChangeTag: undefined, operation: 'clear' }); + ctx.emit('clearAll', event); + initCategoryDisplay(); + }; + + // 保存 + const createFilterFn = (filterName: string) => { + ctx.emit('createFilter', { name: filterName, selectedTags: getSelectedTagsExceptKeyword(), keyword: innerSearchKey.value }); + }; + + const onCategoryItemClick = (item: ICategorySearchTagItem) => { + updateSelectedTags(item, false); + setTimeout(() => { + currentSelectTag.value = item; + if (currentSelectTag.value.type === 'label') { + currentSelectTag.value = mergeToLabel(currentSelectTag.value); + } + currentSelectTag.value.title = setTitle(currentSelectTag.value, currentSelectTag.value.type || '', ''); + inputRef.value.openMenu(); + inputRef.value.focus(); + }, DROPDOWN_ANIMATION_TIMEOUT); + }; + + const searchKeyChangeEvent = (event: Event) => { + innerSearchKey.value = (event.target as HTMLInputElement).value; + enterSearch.value = Boolean(innerSearchKey.value); + currentSearchCategory.value = innerSearchKey.value + ? innerCategory.value.filter((item) => item['label'].toLowerCase().includes(innerSearchKey.value.toLowerCase())) + : []; + ctx.emit('searchKeyChange', innerSearchKey.value); + }; + + const searchInputValue = (event: Event) => { + event.preventDefault(); + event.stopPropagation(); + // 当有分类正在选择时输入关键字不处理 + if (!currentSelectTag.value) { + ctx.emit('search', { + selectedTags: getSelectedTagsExceptKeyword(), + searchKey: setSearchKeyTag(), + }); + } + }; + + const searchCategory = (item: ICategorySearchTagItem) => { + if (valueIsArrayTypes.includes(item.type || '')) { + return; + } + updateFieldValue(item, innerSearchKey.value); + updateSelectedTags(item); + innerSearchKey.value = ''; + enterSearch.value = false; + finishChoose(); + }; + + const showCurrentSearchCategory = (tag: ICategorySearchTagItem) => { + isSearchCategory = true; + innerSearchKey.value = ''; + inputRef.value.closeMenu(); + chooseCategory(tag); + setTimeout(() => { + isFocus.value = true; + enterSearch.value = false; + }, DELAY); + }; + + const onInputBackspace = () => { + if (innerSearchKey.value) { + return; + } + if (currentSelectTag.value) { + currentSelectTag.value = undefined; + inputRef.value.closeMenu(); + return; + } + if (innerSelectedTags.value.length) { + const tag = innerSelectedTags.value[innerSelectedTags.value.length - 1]; + removeTag(tag); + } + inputRef.value.closeMenu(); + }; + + const onInputToggle = () => { + showNoDataTips.value = categoryDisplay.value.every((item) => item.isSelected); + }; + + watch( + searchKey, + () => { + innerSearchKey.value = searchKey.value; + searchKeyCache = searchKey.value; + setSearchKeyTag(false); + }, + { immediate: true } + ); + + watch( + [selectedTags, category, defaultSearchField], + () => { + innerSelectedTags.value = cloneDeep(selectedTags.value); + innerCategory.value = cloneDeep(category.value); + init(); + }, + { immediate: true, deep: true } + ); + + watch( + textConfig, + () => { + innerTextConfig.value = textConfig.value; + innerTextConfig.value.createFilter = innerTextConfig.value.createFilter || '保存过滤器'; + innerTextConfig.value.filterTitle = innerTextConfig.value.filterTitle || '过滤器标题'; + showSearchConfig.value.keywordDescription = showSearchCategory.value.keywordDescription || getSearchMessage; + showSearchConfig.value.fieldDescription = showSearchCategory.value.fieldDescription || getFindingMessage; + showSearchConfig.value.categoryDescription = showSearchCategory.value.categoryDescription || '请选择筛选条件:'; + setTimeout(() => { + const keyword = innerSelectedTags.value.find((item) => item.field === SearchKeyField); + if (keyword) { + keyword.label = innerTextConfig.value.keywordName || '关键字'; + keyword.title = `${keyword.label}:${keyword.value?.label}`; + } + }); + }, + { immediate: true, deep: true } + ); + + watch( + showSearchCategory, + () => { + const customConfig = + typeof showSearchCategory.value === 'boolean' + ? { + keyword: showSearchCategory.value, + field: showSearchCategory.value, + category: showSearchCategory.value, + } + : showSearchCategory.value; + showSearchConfig.value = { ...showSearchConfig.value, ...customConfig }; + }, + { immediate: true, deep: true } + ); + + watch( + () => extendConfig?.value, + () => { + merge(operationConfig, extendConfig?.value || {}); + }, + { immediate: true, deep: true } + ); + + ctx.expose({ + chooseItem, + chooseItems, + getTextInputValue, + getNumberRangeValue, + searchCategory, + }); + + onMounted(() => scrollToTail(true)); + + provide(categorySearchInjectionKey, { + rootRef, + rootCtx: ctx, + id, + innerTextConfig, + tagMaxWidth, + inputReadOnly, + placeholder, + innerSearchKey, + innerSelectedTags, + isHover, + isFocus, + enterSearch, + showSearchCategory, + categoryDisplay, + showSearchConfig, + showNoDataTips, + searchField, + currentSearchCategory, + ComponentMap, + currentSelectTag, + filterNameRules, + joinLabelTypes, + chooseItem, + onSearchKeyTagClick, + clearFilter, + onCategoryItemClick, + removeTag, + chooseItems, + getTextInputValue, + getNumberRangeValue, + createFilterFn, + searchKeyChangeEvent, + searchInputValue, + searchCategory, + showCurrentSearchCategory, + onInputBackspace, + onInputToggle, + }); + + function init() { + setValue(innerCategory.value); + setValue(innerSelectedTags.value, true); + initCategoryDisplay(true); + if (defaultSearchField.value.length && innerCategory.value.length) { + searchField.value = innerCategory.value.filter( + (item) => defaultSearchField.value.includes(item.field) && !valueIsArrayTypes.includes(item.type || '') + ); + } + // 初始化时判断已选中分类中最后一项是否赋值,未赋值则识别为正在处理的分类,优先显示赋值下拉列表 + if (innerSelectedTags.value.length) { + const [lastItem] = innerSelectedTags.value.slice(-1); + const isNull = lastItem.value?.[lastItem.filterKey || 'label'] === undefined; + currentSelectTag.value = + isNull && (lastItem.value?.value === undefined || (Array.isArray(lastItem.value.value) && lastItem.value.value.length === 0)) + ? lastItem + : undefined; + } + if (searchKeyCache) { + innerSearchKey.value = searchKeyCache; + setSearchKeyTag(false); + } + } + + function updateFieldValue(field: ICategorySearchTagItem, value: any) { + const result: Record = {}; + const filterKey = field.filterKey || 'label'; + const colorKey = field.colorKey || 'color'; + result[filterKey] = value; + if (field.type === 'radio') { + field.value!.value = { [filterKey]: value }; + } + if (field.type === 'textInput') { + field.value!.value = value; + } + if (field.type === 'label') { + if (field.options![0] && !field.options![0].$label) { + mergeToLabel(field); + } + result[colorKey] = COLORS[Math.floor(COLORS.length * Math.random())]; + result['$label'] = `${value}_${result[colorKey]}`; + } + if (joinLabelTypes.includes(field.type || '')) { + field.value!.value = [result]; + } + field.value![filterKey] = value; + field.value!.cache = cloneDeep(field.value!.value); + field.title = setTitle(field, field.type || '', value); + } + + function chooseCategory(item: ICategorySearchTagItem) { + // 点选分组名称不处理 + if (item.groupLength !== undefined) { + return; + } + setTimeout(() => { + currentSelectTag.value = item; + if (currentSelectTag.value.type === 'label') { + currentSelectTag.value = mergeToLabel(currentSelectTag.value); + } + currentSelectTag.value.title = setTitle(currentSelectTag.value, currentSelectTag.value.type || '', ''); + inputRef.value.openMenu(); + }, DROPDOWN_ANIMATION_TIMEOUT); + updateSelectedTags(item, false); + } + + function clearCurrentSelectTagFromSearch() { + if (currentSelectTag.value) { + if (isSearchCategory) { + isSearchCategory = false; + setTimeout(finishChoose, DELAY); + } + } + } + + function finishChoose() { + currentSelectTag.value = undefined; + inputRef.value.focus(); + } + + function afterDropdownClosed() { + setTimeout(() => { + currentSelectTag.value = undefined; + }, DROPDOWN_ANIMATION_TIMEOUT + 100); + } + + function resolveCategoryDisplay(tag: ICategorySearchTagItem, type: string) { + if (tag.field === SearchKeyField || !categoryDictionary[tag.field]) { + return; + } + handleGroupLength(tag, type === 'delete'); + categoryDictionary[tag.field].isSelected = type === 'delete'; + } + + function resetValue(tag: ICategorySearchTagItem) { + tag.value = valueIsArrayTypes.includes(tag.type || '') ? { value: [] } : { value: undefined }; + tag.value[tag.filterKey || 'label'] = undefined; + return tag; + } + + function getSelectedTagsExceptKeyword(): ICategorySearchTagItem[] { + return showSearchConfig.value.keyword + ? innerSelectedTags.value.filter((item) => item.field !== SearchKeyField) + : innerSelectedTags.value; + } + + function canChange(tag: ICategorySearchTagItem, operation: 'delete' | 'add') { + let changeResult = Promise.resolve(true); + if (beforeTagChange?.value) { + const result = beforeTagChange.value(tag, innerSearchKey.value, operation); + if (typeof result !== 'undefined') { + if (typeof result === 'boolean') { + changeResult = Promise.resolve(result); + } else { + changeResult = result; + } + } + } + return changeResult; + } + + function initCategoryDisplay(isInit = false) { + const selectedTagsField = innerSelectedTags.value.map((item) => item.field); + if (isInit) { + innerCategory.value = cloneDeep(innerCategory.value) || []; + categoryOrder = []; + categoryDictionary = {}; + initGroupAndDictionary(); + initCategoryOrder(); + } + categoryDisplay.value = categoryOrder.map((item) => { + item.isSelected = selectedTagsField.includes(item.field); + handleGroupLength(item, item.isSelected, isInit); + return item; + }); + showNoDataTips.value = categoryDisplay.value.every((item) => item.isSelected); + } + + function handleGroupLength(tag: ICategorySearchTagItem, isSelected: boolean, isInit = false) { + if (categoryInGroup.value && tag.group) { + const group = categoryDictionary[tag.group]; + const len = group.groupLength; + group.groupLength = isSelected ? len - 1 : isInit ? len : len + 1; + group.isSelected = group.groupLength === 0; + } + } + + function initGroupAndDictionary() { + innerCategory.value.forEach((item) => { + if (categoryInGroup.value && item.group) { + if (categoryDictionary[item.group]) { + categoryDictionary[item.group].groupLength++; + } else { + categoryDictionary[item.group] = { groupName: item.group, groupLength: 1, children: [] }; + } + categoryDictionary[item.group].children.push(item); + } + categoryDictionary[item.field] = item; + }); + } + + function initCategoryOrder() { + const keys = groupOrderConfig.value.length ? groupOrderConfig.value : Object.keys(categoryDictionary); + keys.forEach((key) => { + const item = categoryDictionary[key]; + if (item) { + if (categoryInGroup.value) { + if (item.groupName) { + categoryOrder.push(item, ...item.children); + } else if (!item.group) { + categoryOrder.push(item); + } + } else { + categoryOrder.push(item); + } + } + }); + } + + function setSearchKeyTag(isSearch = true) { + const result = innerSearchKey.value || searchKeyCache; + if (showSearchConfig.value.keyword) { + const existingSearchKeyTag = innerSelectedTags.value.find((tag) => tag.field === SearchKeyField); + if (existingSearchKeyTag && !isSearch && innerSearchKey.value === '') { + removeTag(existingSearchKeyTag); + } else if (innerSearchKey.value && innerSearchKey.value !== existingSearchKeyTag?.value?.value) { + createSearchKeyTag(isSearch); + } + } + innerSearchKey.value = ''; + if (isSearch) { + setTimeout(() => { + enterSearch.value = false; + }, DELAY); + } + return result; + } + + function createSearchKeyTag(isSearch: boolean) { + const label = innerTextConfig.value.keywordName || '关键字'; + const searchKeyTag: ICategorySearchTagItem = { + options: [], + field: SearchKeyField, + label: label, + type: 'keyword', + title: `${label}:${innerSearchKey.value}`, + value: { + label: innerSearchKey.value, + value: innerSearchKey.value, + cache: innerSearchKey.value, + }, + }; + updateSelectedTags(searchKeyTag, isSearch); + searchKeyCache = innerSearchKey.value; + innerSearchKey.value = ''; + } + + function updateSelectedTags(tag: ICategorySearchTagItem, valueChanged = true) { + canChange(tag, 'add').then((val) => { + if (!val) { + return; + } + const index = innerSelectedTags.value.map((item) => item.field).indexOf(tag.field); + if (index > -1) { + if (!tag.value?.value) { + // 通过输入选择分类时避免空值覆盖已选值 + merge(tag, innerSelectedTags.value[index]); + } + innerSelectedTags.value[index] = tag; + } else { + innerSelectedTags.value.push(tag); + } + if (valueChanged) { + // 只在新增标签时位移滚动条 + if (scrollToTailFlag) { + setTimeout(scrollToTail); + } + ctx.emit('selectedTagsChange', { + selectedTags: getSelectedTagsExceptKeyword(), + currentChangeTag: tag, + operation: 'add', + }); + isSearchCategory = false; + } else { + resolveCategoryDisplay(tag, 'delete'); + } + }); + } + + // 判断滚动条是否存在,如果存在自动滚动到末尾的输入框 + function scrollToTail(isInit?: boolean) { + const dom = scrollBarRef.value; + if (toggleScrollToTail.value && dom && dom.scrollWidth > dom.clientWidth) { + if (isInit) { + dom.scrollLeft = dom.scrollWidth - dom.clientWidth; + } else { + inputRef.value.scrollIntoView(); + } + } else if (!isInit) { + // 初始化不聚焦,避免展开下拉 + inputRef.value.focus(); + } + } + + function setValue(data: ICategorySearchTagItem[], isSelectedTags = false) { + if (Array.isArray(data) && data.length) { + data.forEach((item) => { + if (isSelectedTags && innerCategory.value) { + let result = ''; + const originItem = innerCategory.value.find((categoryItem) => categoryItem.field === item.field); + mergeWith(item, originItem, mergeCheck); + if (item.value?.value) { + item.value.cache = cloneDeep(item.value.value); + result = joinLabelTypes.includes(item.type || '') ? getItemValue(item.value.value, item.filterKey || 'label') : ''; + } + item.title = setTitle(item, item.type || '', result); + if (item.type === 'label' && item.options?.[0] && !item.options[0].$label) { + mergeToLabel(item); + } + } else { + item = initCategoryItem(item); + } + }); + } + } + + function setTitle(tag: ICategorySearchTagItem, type: string, result?: string) { + return joinLabelTypes.includes(type) + ? `${tag.label}: ${result || ''}` + : `${tag.label}: ${result || (tag.value && tag.value[tag.filterKey || 'label']) || ''}`; + } + + function mergeCheck(objValue: any, srcValue: any, key: string) { + if (key === 'options' && objValue !== srcValue) { + return srcValue; + } + } + + // checkbox | label 将选中项对应filterKey的值合并的方法,当前多选已通过data展示,可考虑移除 + function getItemValue(value: any, key: string) { + if (value && Array.isArray(value)) { + const result = value.map((item) => item[key]); + return result.join(','); + } + return ''; + } + + // label 合并名称和颜色字段赋给tag,待[tag]支持传入对象后可移除 + function mergeToLabel(obj: ICategorySearchTagItem) { + if (obj?.options && Array.isArray(obj.options)) { + obj.options.forEach((item) => { + item.$label = `${item[obj.filterKey || 'label']}_${item[obj.colorKey || 'color']}`; + }); + } + return obj; + } + + // 初始化tag的value属性:{filterKey | label, value, data} + function initCategoryItem(item: ICategorySearchTagItem) { + const preValue: Record = valueIsArrayTypes.includes(item.type || '') ? { value: [] } : { value: undefined }; + preValue[item.filterKey || 'label'] = undefined; + if (item.value) { + for (const prop in preValue) { + if (item.value[prop] === undefined) { + item.value[prop] = preValue[prop]; + } + } + } else { + item.value = preValue; + } + item.value.cache = (item.value.value && typeof item.value.value === 'object' && cloneDeep(item.value.value)) || item.value.value; + return item; + } + + return { + rootRef, + scrollBarRef, + inputRef, + isHover, + containerClasses, + innerSelectedTags, + joinLabelTypes, + showExtendedConfig, + operationConfig, + removeTag, + onSearch, + }; +} diff --git a/packages/devui-vue/docs/components/category-search/index.md b/packages/devui-vue/docs/components/category-search/index.md new file mode 100644 index 0000000000..b793d8227b --- /dev/null +++ b/packages/devui-vue/docs/components/category-search/index.md @@ -0,0 +1,529 @@ +# CategorySearch 分类搜索 + +按类型进行搜索,目前支持的类型包括:`radio`、`checkbox`、`label`、`textInput`,`numberRange`,`keyword`。 + +### 基本用法 + +:::demo + +```vue + + + +``` + +::: + +### 自定义展示模板 + +:::demo 自定义分类下拉展示模板和选中后的标签展示模板。分类下拉展示模板的插槽名为`${field}Menu`,标签展示模板的插槽名为`${field}Tag`,`field`为分类的字段名;插槽参数为`category`参数中对应分类的数据。 + +```vue + + + +``` + +::: + +### 自定义扩展按钮 + +:::demo 自定义分类下拉展示模板和选中后的标签展示模板。分类下拉展示模板的插槽名为`${field}Menu`,标签展示模板的插槽名为`${field}Tag`,`field`为分类的字段名;插槽参数为`category`参数中对应分类的数据。 + +```vue + + + +``` + +::: + +### CategorySearch 参数 + +| 参数名 | 类型 | 默认值 | 说明 | 跳转 | +| :------------------- | :--------------------------------------------------------------------------------------------------- | :----- | :--------------------------------------------------------------------------------------- | :-------------------------------- | +| category | [ICategorySearchTagItem[]](#icategorysearchtagitem) | [] | 必选,传入分类搜索源数据 | [基本用法](#基本用法) | +| default-search-field | `String[]` | [] | 可选,配置输入关键字时可在哪些分类中筛选 | [基本用法](#基本用法) | +| selected-tags | [ICategorySearchTagItem[]](#icategorysearchtagitem) | [] | 可选,传入需要默认选中的分类数据 | [基本用法](#基本用法) | +| search-key | `string` | '' | 可选,搜索框内的默认展示值 | [基本用法](#基本用法) | +| placeholder | `string` | '' | 可选, 自定义搜索输入框占位文字 | [基本用法](#基本用法) | +| input-read-only | `boolean` | false | 可选,是否可通过搜索框输入关键字搜索,`true`则无法输入关键字,仅可根据提供的分类数据筛选 | | +| before-tag-change | `(tag: ICategorySearchTagItem, searchKey: string, operation: string) => boolean \| Promise` | -- | 可选,改变标签前调用的方法,返回 false 可以阻止分类值改变 | | +| show-search-category | `boolean \| SearchConfig` | true | 可选,是否显示搜索关键字下拉菜单 | | +| filter-name-rules | `Record[]` | -- | 可选,配置保存过滤器标题的校验规则,详细规则参见 vue-devui 库的 form 组件 | [基本用法](#基本用法) | +| extend-config | [ExtendConfig](#extendconfig) | -- | 可选,配置右侧扩展按钮功能 | [自定义扩展按钮](#自定义扩展按钮) | +| tag-max-width | `number` | -- | 可选,单个过滤条件的最大宽度,超过则显示省略号,不设置则不限制 | [自定义展示模板](#自定义展示模板) | + +### CategorySearch 事件 + +| 事件名 | 回调参数 | 说明 | +| :----------------- | :---------------------------- | :----------------------------------------------------------- | +| search | `Function(SearchEvent)` | 点击搜索按钮时触发,返回值为当前选中分类数据和搜索框中关键字 | +| selectedTagsChange | `Function(SelectedTagsEvent)` | 分类数据变更时触发,返回值为当前选中的分类数据 | +| createFilter | `Function(CreateFilterEvent)` | 点击保存按钮时触发,返回值为当前选中分类数据和搜索框中关键字 | +| clearAll | `Function(e:Event)` | 点击清除按钮时触发,返回值为当前选中分类数据和搜索框中关键字 | +| searchKeyChange | `Function(val: string)` | 搜索关键字变更时触发,返回值为输入框的绑定值 | + +### CategorySearch 插槽 + +| 插槽名 | 说明 | 参数 | 跳转 | +| :----------- | :------------------------------------------------------------------------------------------------------------------ | :--------------------------------------------------------- | :-------------------------------- | +| ${field}Menu | 自定义不同分类的下拉面板,`field`为分类的字段名,tagOption 参数为当前分类的数据,close 参数为关闭当前下拉面板的方法 | `{ tagOption: ICategorySearchTagItem, close: () => void }` | [自定义展示模板](#自定义展示模板) | +| ${field}Tag | 自定义不同分类的选中标签,`field`为分类的字段名,tag 参数为当前分类的数据 | `{ tag: ICategorySearchTagItem }` | [自定义展示模板](#自定义展示模板) | +| clear | 自定义清空图标 | | | +| save | 自定义保存图标 | | | +| more | 自定义更多图标 | | | +| operation | 自定义除`clear`、`save`、`more`外的其他图标 | | [自定义扩展按钮](#自定义扩展按钮) | + +### CategorySearch 方法 + +| 方法名 | 说明 | 参数 | +| :------------------ | :------------------------------------------------------------------------- | :------------------------------------------------------------ | +| chooseItem | 调用组件方法处理选中数据,针对`radio`类型,参数为当前 tag 和选中项 | (tag: ICategorySearchTagItem, chooseItem: ITagOption) => void | +| chooseItems | 调用组件方法处理选中数据,针对`checkbox \| label`类型,参数为当前 tag | (tag: ICategorySearchTagItem) => void | +| getTextInputValue | 调用组件方法处理选中数据,针对`textInput`类型,参数为当前 tag 和输入内容 | (tag: ICategorySearchTagItem, inputValue: string) => void | +| getNumberRangeValue | 调用组件方法处理选中数据,针对`numberRange`类型,参数为当前 tag 和输入内容 | (tag: ICategorySearchTagItem, rangeValue: number[]) => void | + +### 类型定义 + +#### ICategorySearchTagItem + +```ts +interface ICategorySearchTagItem { + /** + * 搜索字段,tag的键,用于区分不同的分类,需要唯一 + */ + field: string; + /** + * tag 键的显示值 + */ + label: string; + /** + * 配置项可生产的tag类型 + */ + type?: CategorySearchTagType; + /** + * 配置项所属的分组 + */ + group?: string; + /** + * tag 值的选择项数据 + */ + options?: Array; + /** + * 用于显示的 tag 值的键值,如未设置默认取label + */ + filterKey?: string | 'label'; + /** + * 用于显示的label类型中色值的键值,如未设置默认取color + */ + colorKey?: string | 'color'; + /** + * 已选中值 + */ + value?: { + label?: string; + value?: string | ITagOption | Array; + cache?: string | ITagOption | Array; + [propName: string]: any; + }; + /** + * textInput 类型设置最大长度 + */ + maxLength?: number; + /** + * textInput | numberRange 类型设置占位符,numberRange需传入对象分别设置左右 + */ + placeholder?: string | { left: string; right: string }; + /** + * textInput 表单校验规则 + */ + validateRules?: any[]; + [propName: string]: any; +} +``` + +#### CategorySearchTagType + +```ts +type CategorySearchTagType = 'radio' | 'checkbox' | 'label' | 'textInput' | 'numberRange' | 'keyword'; +``` + +#### ITagOption + +```ts +interface ITagOption { + /** + * 选项,label和color默认都会取对应的 filterKey 和 colorKey,如未设置取默认值 + */ + label?: string; // 通用默认属性,用于设置分类名称 + color?: string; // label 专用,用于设置标签颜色 + [propName: string]: any; +} +``` + +#### SearchEvent + +```ts +interface SearchEvent { + selectedTags: Array; + searchKey: string; +} +``` + +#### SelectedTagsEvent + +```ts +interface SelectedTagsEvent { + selectedTags: Array; + currentChangeTag: ICategorySearchTagItem; + operation: 'add' | 'delete' | 'clear'; +} +``` + +#### CreateFilterEvent + +```ts +interface CreateFilterEvent { + name: string; + selectedTags: Array; + keyword: string; +} +``` + +#### ExtendConfig + +```ts +interface ExtendConfig { + show?: boolean; + clear?: { + show?: boolean; + disabled?: boolean; + }; + save?: { + show?: boolean; + disabled?: boolean; + }; + more?: { + show?: boolean; + disabled?: boolean; + }; +} +``` From 2907c9c82873bd46b6387d644a2b91c650cb3ff0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A1=8C=E8=A8=80?= <2311595895@qq.com> Date: Tue, 26 Mar 2024 11:18:53 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E8=99=9A=E6=8B=9F=E6=BB=9A=E5=8A=A8=E7=9A=84=E8=A1=A8=E6=A0=BC?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=20(#1796)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../devui-cli/templates/vue-devui.js | 1 + packages/devui-vue/devui/data-grid/index.ts | 14 + .../src/components/fix-head-grid.tsx | 88 + .../data-grid/src/components/grid-body.tsx | 135 ++ .../data-grid/src/components/grid-head.tsx | 77 + .../data-grid/src/components/grid-icons.tsx | 56 + .../data-grid/src/components/grid-td.tsx | 101 + .../src/components/grid-th-filter.tsx | 75 + .../components/grid-th-multiple-filter.tsx | 48 + .../src/components/grid-th-single-filter.tsx | 30 + .../data-grid/src/components/grid-th.tsx | 78 + .../src/components/normal-head-grid.tsx | 60 + .../src/composables/use-column-sort.ts | 39 + .../src/composables/use-data-grid-drag.ts | 44 + .../src/composables/use-data-grid-scroll.ts | 95 + .../src/composables/use-data-grid-tree.ts | 281 +++ .../src/composables/use-data-grid.ts | 350 +++ .../data-grid/src/composables/use-grid-th.ts | 168 ++ .../src/composables/use-overflow-tooltip.ts | 90 + .../devui-vue/devui/data-grid/src/const.ts | 13 + .../devui/data-grid/src/data-grid-types.ts | 333 +++ .../devui/data-grid/src/data-grid.scss | 655 ++++++ .../devui/data-grid/src/data-grid.tsx | 106 + .../devui-vue/devui/data-grid/src/utils.ts | 148 ++ packages/devui-vue/devui/locale/lang/en-us.ts | 4 + packages/devui-vue/devui/locale/lang/zh-cn.ts | 4 + packages/devui-vue/devui/style/global.scss | 37 + packages/devui-vue/devui/style/index.scss | 1 + .../docs/components/data-grid/index.md | 1999 +++++++++++++++++ packages/devui-vue/package.json | 1 + 30 files changed, 5131 insertions(+) create mode 100644 packages/devui-vue/devui/data-grid/index.ts create mode 100644 packages/devui-vue/devui/data-grid/src/components/fix-head-grid.tsx create mode 100644 packages/devui-vue/devui/data-grid/src/components/grid-body.tsx create mode 100644 packages/devui-vue/devui/data-grid/src/components/grid-head.tsx create mode 100644 packages/devui-vue/devui/data-grid/src/components/grid-icons.tsx create mode 100644 packages/devui-vue/devui/data-grid/src/components/grid-td.tsx create mode 100644 packages/devui-vue/devui/data-grid/src/components/grid-th-filter.tsx create mode 100644 packages/devui-vue/devui/data-grid/src/components/grid-th-multiple-filter.tsx create mode 100644 packages/devui-vue/devui/data-grid/src/components/grid-th-single-filter.tsx create mode 100644 packages/devui-vue/devui/data-grid/src/components/grid-th.tsx create mode 100644 packages/devui-vue/devui/data-grid/src/components/normal-head-grid.tsx create mode 100644 packages/devui-vue/devui/data-grid/src/composables/use-column-sort.ts create mode 100644 packages/devui-vue/devui/data-grid/src/composables/use-data-grid-drag.ts create mode 100644 packages/devui-vue/devui/data-grid/src/composables/use-data-grid-scroll.ts create mode 100644 packages/devui-vue/devui/data-grid/src/composables/use-data-grid-tree.ts create mode 100644 packages/devui-vue/devui/data-grid/src/composables/use-data-grid.ts create mode 100644 packages/devui-vue/devui/data-grid/src/composables/use-grid-th.ts create mode 100644 packages/devui-vue/devui/data-grid/src/composables/use-overflow-tooltip.ts create mode 100644 packages/devui-vue/devui/data-grid/src/const.ts create mode 100644 packages/devui-vue/devui/data-grid/src/data-grid-types.ts create mode 100644 packages/devui-vue/devui/data-grid/src/data-grid.scss create mode 100644 packages/devui-vue/devui/data-grid/src/data-grid.tsx create mode 100644 packages/devui-vue/devui/data-grid/src/utils.ts create mode 100644 packages/devui-vue/devui/style/global.scss create mode 100644 packages/devui-vue/devui/style/index.scss create mode 100644 packages/devui-vue/docs/components/data-grid/index.md diff --git a/packages/devui-vue/devui-cli/templates/vue-devui.js b/packages/devui-vue/devui-cli/templates/vue-devui.js index 402a9da792..d0bf04a6fe 100644 --- a/packages/devui-vue/devui-cli/templates/vue-devui.js +++ b/packages/devui-vue/devui-cli/templates/vue-devui.js @@ -25,6 +25,7 @@ import type { App } from 'vue'; ${imports.join('\n')} import './style/devui.scss'; +import './style/index.scss'; const installs = [ ${installs.join(',\n ')} diff --git a/packages/devui-vue/devui/data-grid/index.ts b/packages/devui-vue/devui/data-grid/index.ts new file mode 100644 index 0000000000..bd2f12beaf --- /dev/null +++ b/packages/devui-vue/devui/data-grid/index.ts @@ -0,0 +1,14 @@ +import type { App } from "vue"; +import DataGrid from './src/data-grid'; + +export * from './src/data-grid-types'; +export { DataGrid } + +export default { + title: 'DataGrid 数据表格', + category: '数据展示', + status: '100%', + install(app: App): void { + app.component(DataGrid.name, DataGrid); + } +}; diff --git a/packages/devui-vue/devui/data-grid/src/components/fix-head-grid.tsx b/packages/devui-vue/devui/data-grid/src/components/fix-head-grid.tsx new file mode 100644 index 0000000000..f4b6eff6e6 --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/components/fix-head-grid.tsx @@ -0,0 +1,88 @@ +import { defineComponent, inject, ref, watch, onMounted, onBeforeMount } from 'vue'; +import { useNamespace } from '../../../shared/hooks/use-namespace'; +import GridHead from './grid-head'; +import GridBody from './grid-body'; +import { DataGridInjectionKey } from '../data-grid-types'; +import type { DataGridContext } from '../data-grid-types'; +import { useDataGridLazy } from '../composables/use-data-grid-scroll'; + +export default defineComponent({ + name: 'FixHeadGrid', + setup() { + const ns = useNamespace('data-grid'); + const { + scrollRef, + headBoxRef, + showHeader, + bodyContentWidth, + bodyContentHeight, + renderColumnData, + renderFixedLeftColumnData, + renderFixedRightColumnData, + renderRowData, + translateX, + translateY, + bodyScrollLeft, + rootCtx, + } = inject(DataGridInjectionKey) as DataGridContext; + const hasScrollbar = ref(false); + let resizeObserver: ResizeObserver; + useDataGridLazy(scrollRef); + + const isHaveScrollbar = () => { + if (scrollRef.value) { + hasScrollbar.value = scrollRef.value.scrollHeight > scrollRef.value.clientHeight; + } + }; + + watch(bodyContentHeight, isHaveScrollbar, { immediate: true }); + + onMounted(() => { + if (scrollRef.value) { + resizeObserver = new ResizeObserver(isHaveScrollbar); + resizeObserver.observe(scrollRef.value); + } + }); + + onBeforeMount(() => { + resizeObserver?.disconnect(); + }); + + return () => ( +
    + {showHeader.value && ( +
    +
    + +
    + )} +
    +
    +
    + + {Boolean(renderRowData.value.length) ? ( + + ) : ( +
    + {rootCtx.slots.empty?.()} +
    + )} +
    +
    + ); + }, +}); \ No newline at end of file diff --git a/packages/devui-vue/devui/data-grid/src/components/grid-body.tsx b/packages/devui-vue/devui/data-grid/src/components/grid-body.tsx new file mode 100644 index 0000000000..e1b8afe1cf --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/components/grid-body.tsx @@ -0,0 +1,135 @@ +import { defineComponent, toRefs, inject, ref, Teleport } from 'vue'; +import { FlexibleOverlay } from '../../../overlay'; +import GridTd from './grid-td'; +import { gridBodyProps, DataGridInjectionKey } from '../data-grid-types'; +import type { GridBodyProps, DataGridContext, InnerRowData } from '../data-grid-types'; +import { useNamespace } from '../../../shared/hooks/use-namespace'; +import { useOverflowTooltip } from '../composables/use-overflow-tooltip'; +import { ToggleTreeIcon, DataGridCheckboxClass } from '../const'; + +export default defineComponent({ + name: 'GridBody', + props: gridBodyProps, + setup(props: GridBodyProps) { + const ns = useNamespace('data-grid'); + const { rowClass, rootCtx } = inject(DataGridInjectionKey) as DataGridContext; + const { rowData, columnData, leftColumnData, rightColumnData, translateX, translateY, bodyScrollLeft } = toRefs(props); + const currentRowIndex = ref(); + const { + showTooltip, + originRef, + tooltipContent, + tooltipPosition, + tooltipClassName, + onCellMouseenter, + onCellMouseleave, + onOverlayMouseenter, + onOverlayMouseleave + } = useOverflowTooltip(); + const trClasses = (rowData: InnerRowData, rowIndex: number) => { + const realRowClass = typeof rowClass.value === 'string' ? rowClass.value : rowClass.value(rowData, rowIndex); + return { + [ns.e('tr')]: true, + [realRowClass]: true, + 'hover-tr': currentRowIndex.value === rowIndex, + }; + }; + const onRowClick = (e: Event, rowData: InnerRowData, rowIndex: number) => { + const composedPath = e.composedPath() as HTMLElement[]; + if (composedPath.some((item) => item.classList?.contains(ToggleTreeIcon) || item.classList?.contains(DataGridCheckboxClass))) { + return; + } + rootCtx.emit('rowClick', { row: { ...rowData }, renderRowIndex: rowIndex, flattenRowIndex: rowData.$rowIndex }); + }; + const onTrMouseenterOrLeave = (rowIndex: number | undefined) => { + currentRowIndex.value = rowIndex; + }; + + return () => ( + <> + {Boolean(leftColumnData.value.length) && ( +
    + {rowData.value.map((itemRow, rowIndex) => ( +
    onRowClick(e, itemRow, rowIndex)} + onMouseenter={() => onTrMouseenterOrLeave(rowIndex)} + onMouseleave={() => onTrMouseenterOrLeave(undefined)}> + {leftColumnData.value.map((cellData, cellIndex) => ( + + ))} +
    + ))} +
    + )} + + {Boolean(rightColumnData.value.length) && ( +
    + {rowData.value.map((itemRow, rowIndex) => ( +
    onRowClick(e, itemRow, rowIndex)} + onMouseenter={() => onTrMouseenterOrLeave(rowIndex)} + onMouseleave={() => onTrMouseenterOrLeave(undefined)}> + {rightColumnData.value.map((cellData, cellIndex) => ( + + ))} +
    + ))} +
    + )} + +
    + {rowData.value.map((itemRow, rowIndex) => ( +
    onRowClick(e, itemRow, rowIndex)} + onMouseenter={() => onTrMouseenterOrLeave(rowIndex)} + onMouseleave={() => onTrMouseenterOrLeave(undefined)}> + {columnData.value.map((cellData) => ( + + ))} +
    + ))} +
    + + + {tooltipContent.value} + + + + ); + }, +}); \ No newline at end of file diff --git a/packages/devui-vue/devui/data-grid/src/components/grid-head.tsx b/packages/devui-vue/devui/data-grid/src/components/grid-head.tsx new file mode 100644 index 0000000000..fa486dc0ad --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/components/grid-head.tsx @@ -0,0 +1,77 @@ +import { defineComponent, toRefs, Teleport } from 'vue'; +import { useNamespace } from '../../../shared/hooks/use-namespace'; +import { FlexibleOverlay } from '../../../overlay'; +import GridTh from './grid-th'; +import { gridHeadProps } from '../data-grid-types'; +import type { GridHeadProps } from '../data-grid-types'; +import { useOverflowTooltip } from '../composables/use-overflow-tooltip'; + +export default defineComponent({ + name: 'GridHead', + props: gridHeadProps, + setup(props: GridHeadProps) { + const ns = useNamespace('data-grid'); + const { columnData, leftColumnData, rightColumnData, translateX, bodyScrollLeft } = toRefs(props); + const { + showTooltip, + originRef, + tooltipContent, + tooltipPosition, + tooltipClassName, + onCellMouseenter, + onCellMouseleave, + onOverlayMouseenter, + onOverlayMouseleave + } = useOverflowTooltip(); + + return () => ( + <> + {Boolean(leftColumnData.value.length) && ( +
    + {leftColumnData.value.map((item, index) => ( + + ))} +
    + )} + + {Boolean(rightColumnData.value.length) && ( +
    + {rightColumnData.value.map((item, index) => ( + + ))} +
    + )} + +
    + {columnData.value.map((item) => ( + + ))} +
    + + + + {tooltipContent.value} + + + + ); + }, +}); \ No newline at end of file diff --git a/packages/devui-vue/devui/data-grid/src/components/grid-icons.tsx b/packages/devui-vue/devui/data-grid/src/components/grid-icons.tsx new file mode 100644 index 0000000000..01d628725b --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/components/grid-icons.tsx @@ -0,0 +1,56 @@ +export function SortIcon(): JSX.Element { + return ( + + + + + + + + + + + + + + + + ); +} + +export function FilterIcon(): JSX.Element { + return ( + + + + + + + + ); +} + +export function ExpandIcon(): JSX.Element { + return ( + + + + + + + ); +} + +export function FoldIcon(): JSX.Element { + return ( + + + + + + + ); +} \ No newline at end of file diff --git a/packages/devui-vue/devui/data-grid/src/components/grid-td.tsx b/packages/devui-vue/devui/data-grid/src/components/grid-td.tsx new file mode 100644 index 0000000000..2e86f1ed22 --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/components/grid-td.tsx @@ -0,0 +1,101 @@ +import { defineComponent, toRefs, inject } from 'vue'; +import { useNamespace } from '../../../shared/hooks/use-namespace'; +import { Checkbox } from '../../../checkbox'; +import { ExpandIcon, FoldIcon } from './grid-icons'; +import { gridTdProps, DataGridInjectionKey } from '../data-grid-types'; +import type { GridTdProps, DataGridContext } from '../data-grid-types'; +import { ToggleTreeIcon, DataGridCheckboxClass } from '../const'; + +export default defineComponent({ + name: 'GridTd', + props: gridTdProps, + setup(props: GridTdProps) { + const ns = useNamespace('data-grid'); + const { indent, size, cellClass, rootCtx, isTreeGrid, toggleRowExpansion, toggleRowChecked } = inject( + DataGridInjectionKey + ) as DataGridContext; + const { rowData, cellData, rowIndex, mouseenterCb, mouseleaveCb } = toRefs(props); + + const getColumnIndex = () => Number(cellData.value.$columnId.split('-')[1]); + + const tdClasses = () => { + const realTdClass = + typeof cellClass.value === 'string' + ? cellClass.value + : cellClass.value(rowData.value, rowIndex.value, cellData.value, getColumnIndex()); + return { + [ns.e('td')]: true, + [ns.m(cellData.value.align)]: true, + [ns.em('td', cellData.value.type)]: true, + [ns.em('td', size.value)]: true, + [realTdClass]: true, + } + }; + + const onCellClick = (e: Event) => { + const composedPath = e.composedPath() as HTMLElement[]; + if (composedPath.some((item) => item.classList?.contains(ToggleTreeIcon) || item.classList?.contains(DataGridCheckboxClass))) { + return; + } + rootCtx.emit('cellClick', { + row: { ...rowData.value }, + renderRowIndex: rowIndex.value, + flattenRowIndex: rowData.value.$rowIndex, + column: { ...cellData.value }, + columnIndex: getColumnIndex(), + }); + }; + + const toggleExpand = () => { + toggleRowExpansion(rowData.value); + }; + + const onCheckedChange = () => { + toggleRowChecked(rowData.value); + }; + + const cellTypeMap = { + checkable: () => ( + + ), + index: () => rowData.value.$rowIndex! + 1, + default: () => rowData.value[cellData.value.field], + }; + + return () => ( +
    mouseenterCb.value(e, cellData.value.showOverflowTooltip)} + onMouseleave={(e) => mouseleaveCb.value(e, cellData.value.showOverflowTooltip)}> + {isTreeGrid.value && cellData.value.$showExpandTreeIcon && ( + <> + + {rowData.value.$expand ? ( + + ) : ( + + )} + + )} + {cellData.value.cellRender?.(rowData.value, rowData.value.$rowIndex!, rowData.value[cellData.value.field], getColumnIndex()) ?? + cellTypeMap[cellData.value.type || 'default']()} +
    + ); + }, +}); \ No newline at end of file diff --git a/packages/devui-vue/devui/data-grid/src/components/grid-th-filter.tsx b/packages/devui-vue/devui/data-grid/src/components/grid-th-filter.tsx new file mode 100644 index 0000000000..68b4c28a11 --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/components/grid-th-filter.tsx @@ -0,0 +1,75 @@ +import { defineComponent, ref, onMounted, onUnmounted } from 'vue'; +import type { SetupContext } from 'vue'; +import { useNamespace } from '../../../shared/hooks/use-namespace'; +import { Dropdown } from '../../../dropdown'; +import { FilterIcon } from './grid-icons'; +import { gridThFilterProps } from '../data-grid-types'; +import type { FilterListItem, GridThFilterProps } from '../data-grid-types'; +import GridThMultipleFilter from './grid-th-multiple-filter'; +import GridThSingleFilter from './grid-th-single-filter'; + +export default defineComponent({ + name: 'GridThFilter', + props: gridThFilterProps, + emits: ['filterChange'], + setup(props: GridThFilterProps, ctx: SetupContext) { + const ns = useNamespace('data-grid'); + const filterIconRef = ref(); + const showMenu = ref(false); + + const toggleFilterMenu = (status?: boolean) => { + if (typeof status === 'boolean') { + showMenu.value = status; + } else { + showMenu.value = !showMenu.value; + } + }; + + const onConfirm = (e: FilterListItem | FilterListItem[]) => { + toggleFilterMenu(false); + ctx.emit('filterChange', e); + }; + + const onScroll = (e: Event) => { + const scrollElement = e.target as HTMLElement; + if (filterIconRef.value && scrollElement?.contains(filterIconRef.value)) { + toggleFilterMenu(false); + } + }; + + onMounted(() => { + window.addEventListener('scroll', onScroll, true); + }); + + onUnmounted(() => { + window.removeEventListener('scroll', onScroll, true); + }); + + return () => ( + (showMenu.value = val)}> + {{ + default: () => ( + + ), + menu: () => + props.filterMenu?.({ toggleFilterMenu, setFilterStatus: props.setFilterStatus }) ?? + (props.multiple ? ( + + ) : ( + + )), + }} + + ); + }, +}); diff --git a/packages/devui-vue/devui/data-grid/src/components/grid-th-multiple-filter.tsx b/packages/devui-vue/devui/data-grid/src/components/grid-th-multiple-filter.tsx new file mode 100644 index 0000000000..cbebec9b58 --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/components/grid-th-multiple-filter.tsx @@ -0,0 +1,48 @@ +import { defineComponent, withModifiers, getCurrentInstance } from 'vue'; +import type { SetupContext } from 'vue'; +import { createI18nTranslate } from '@devui/shared/components/locale/create'; +import { Button } from '../../../button'; +import { Checkbox } from '../../../checkbox'; +import { gridThFilterProps } from '../data-grid-types'; +import type { GridThFilterProps } from '../data-grid-types'; +import { useGridThMultipleFilter } from '../composables/use-grid-th'; + +export default defineComponent({ + name: 'GridThMultipleFilter', + props: gridThFilterProps, + emits: ['confirm'], + setup(props: GridThFilterProps, ctx: SetupContext) { + const app = getCurrentInstance(); + const t = createI18nTranslate('DDataGrid', app); + const { _checkList, _checkAll, _halfChecked, onCheckAllClick, onItemClick, updateCheckAll, onConfirm } = useGridThMultipleFilter( + props, + ctx + ); + + return () => ( + <> +
    +
    + +
    +
    +
    + {_checkList.value.map((item) => ( +
    { + onItemClick(item); + }, ['self'])}> + +
    + ))} +
    +
    + +
    + + ); + }, +}); \ No newline at end of file diff --git a/packages/devui-vue/devui/data-grid/src/components/grid-th-single-filter.tsx b/packages/devui-vue/devui/data-grid/src/components/grid-th-single-filter.tsx new file mode 100644 index 0000000000..1ca682a181 --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/components/grid-th-single-filter.tsx @@ -0,0 +1,30 @@ +import { defineComponent, ref } from 'vue'; +import type { SetupContext } from 'vue'; +import { gridThFilterProps } from '../data-grid-types'; +import type { GridThFilterProps, FilterListItem } from '../data-grid-types'; + +export default defineComponent({ + props: gridThFilterProps, + emits: ['select'], + setup(props: GridThFilterProps, ctx: SetupContext) { + const selectedItem = ref(); + const handleSelect = (e: FilterListItem) => { + selectedItem.value = e; + ctx.emit('select', e); + }; + + return () => ( +
    + {props.filterList?.map((item) => ( +
    { + handleSelect(item); + }}> + {item.name} +
    + ))} +
    + ); + }, +}); \ No newline at end of file diff --git a/packages/devui-vue/devui/data-grid/src/components/grid-th.tsx b/packages/devui-vue/devui/data-grid/src/components/grid-th.tsx new file mode 100644 index 0000000000..39d9ec9b3a --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/components/grid-th.tsx @@ -0,0 +1,78 @@ +import { defineComponent, toRefs, inject, computed } from 'vue'; +import { useNamespace } from '../../../shared/hooks/use-namespace'; +import { Checkbox } from '../../../checkbox'; +import { SortIcon } from './grid-icons'; +import GridThFilter from './grid-th-filter'; +import { gridThProps, DataGridInjectionKey } from '../data-grid-types'; +import type { GridThProps, DataGridContext } from '../data-grid-types'; +import { useGridThSort, useGridThFilter, useGridThDrag } from '../composables/use-grid-th'; + +export default defineComponent({ + name: 'GridTh', + props: gridThProps, + setup(props: GridThProps) { + const ns = useNamespace('data-grid'); + const { size, allChecked, halfAllChecked, virtualScroll, resizable, addGridThContextToMap, toggleAllRowChecked } = inject( + DataGridInjectionKey + ) as DataGridContext; + const { columnConfig, mouseenterCb, mouseleaveCb } = toRefs(props); + const { direction, doSort, onSortClick, doClearSort } = useGridThSort(columnConfig); + const { filterActive, setFilterStatus, onFilterChange } = useGridThFilter(columnConfig); + const classes = computed(() => ({ + [ns.e('th')]: true, + [ns.m(columnConfig.value.align)]: true, + [ns.e('sticky-th')]: true, + [ns.em('th', size.value)]: true, + [ns.em('th', 'filter-active')]: filterActive.value, + [ns.em('th', 'sort-active')]: Boolean(direction.value), + [ns.em('th', 'operable')]: + columnConfig.value.filterable || + columnConfig.value.sortable || + (!virtualScroll.value && (columnConfig.value.resizable ?? resizable.value)), + })); + const { thRef, onMousedown } = useGridThDrag(columnConfig); + + if (columnConfig.value.sortable) { + addGridThContextToMap(columnConfig.value.field, { doSort, doClearSort }); + } + + const cellTypeMap = { + checkable: () => , + index: () => #, + default: () => {columnConfig.value.header}, + }; + + return () => ( +
    mouseenterCb.value(e, columnConfig.value.showHeadOverflowTooltip)} + onMouseleave={(e) => mouseleaveCb.value(e, columnConfig.value.showHeadOverflowTooltip)}> + {columnConfig.value.headRender ? ( + {columnConfig.value.headRender(columnConfig.value)} + ) : ( + cellTypeMap[columnConfig.value.type || 'default']() + )} + {columnConfig.value.sortable && ( + + )} + {columnConfig.value.filterable && ( + + )} + {!virtualScroll.value && (columnConfig.value.resizable ?? resizable.value) && } +
    + ); + }, +}); \ No newline at end of file diff --git a/packages/devui-vue/devui/data-grid/src/components/normal-head-grid.tsx b/packages/devui-vue/devui/data-grid/src/components/normal-head-grid.tsx new file mode 100644 index 0000000000..dde48042cb --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/components/normal-head-grid.tsx @@ -0,0 +1,60 @@ +import { defineComponent, inject, } from 'vue'; +import { useNamespace } from '../../../shared/hooks/use-namespace'; +import GridHead from './grid-head'; +import GridBody from './grid-body'; +import { DataGridInjectionKey } from '../data-grid-types'; +import type { DataGridContext } from '../data-grid-types'; +import { useDataGridLazy } from '../composables/use-data-grid-scroll'; + +export default defineComponent({ + name: 'NormalHeadGrid', + setup() { + const ns = useNamespace('data-grid'); + const { + scrollRef, + showHeader, + bodyContentWidth, + bodyContentHeight, + renderColumnData, + renderFixedLeftColumnData, + renderFixedRightColumnData, + renderRowData, + translateX, + translateY, + bodyScrollLeft, + rootCtx + } = inject(DataGridInjectionKey) as DataGridContext; + useDataGridLazy(scrollRef); + + return () => ( +
    +
    +
    + {showHeader.value && ( + + )} + {Boolean(renderRowData.value.length) ? ( + + ) : ( +
    + {rootCtx.slots.empty?.()} +
    + )} +
    + ); + }, +}); \ No newline at end of file diff --git a/packages/devui-vue/devui/data-grid/src/composables/use-column-sort.ts b/packages/devui-vue/devui/data-grid/src/composables/use-column-sort.ts new file mode 100644 index 0000000000..e98e615550 --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/composables/use-column-sort.ts @@ -0,0 +1,39 @@ +import type { ColumnConfig, SortDirection, SortMethod, ScrollYParams, GridThContext } from "../data-grid-types"; + +export function useColumnSort(scrollYParams: ScrollYParams, afterSort: () => void) { + const gridThListMap = new Map(); + + // 执行sortMethod对数据源排序 + const execSortMethod = (direction: SortDirection, sortMethod?: SortMethod) => { + const temp = [...scrollYParams.defaultSortRowData]; + if (direction === 'asc') { + scrollYParams.originRowData = temp + .sort((a, b) => (sortMethod ? (sortMethod(a, b) ? 1 : -1) : 0)) + .map((item, index) => ({ ...item, offsetTop: index * 40 })); + } else if (direction === 'desc') { + scrollYParams.originRowData = temp + .sort((a, b) => (sortMethod ? (sortMethod(a, b) ? -1 : 1) : 0)) + .map((item, index) => ({ ...item, offsetTop: index * 40 })); + } else { + scrollYParams.originRowData = temp; + } + afterSort() + }; + + const addGridThContextToMap = (key: ColumnConfig['field'], thCtx: GridThContext) => { + gridThListMap.set(key, thCtx); + } + + const clearAllSortState = () => { + gridThListMap.forEach((item) => { + item.doClearSort(); + }) + } + + // 对外expose,业务手动对某列数据按指定顺序进行排序 + const sort = (key: ColumnConfig['field'], direction: SortDirection) => { + gridThListMap.get(key)?.doSort(direction); + }; + + return { sort, execSortMethod, addGridThContextToMap, clearAllSortState }; +} \ No newline at end of file diff --git a/packages/devui-vue/devui/data-grid/src/composables/use-data-grid-drag.ts b/packages/devui-vue/devui/data-grid/src/composables/use-data-grid-drag.ts new file mode 100644 index 0000000000..4cf4ee34b7 --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/composables/use-data-grid-drag.ts @@ -0,0 +1,44 @@ +import type { Ref } from 'vue'; +import type { InnerColumnConfig } from '../data-grid-types'; + +export function useDataGridColumnDrag( + bodyContentWidth: Ref, + scrollRef: Ref, + renderFixedLeftColumnData: Ref, + renderFixedRightColumnData: Ref, + renderColumnData: Ref +) { + const afterColumnDragend = (columnId: InnerColumnConfig['$columnId'], offset: number) => { + const columnLength = renderColumnData.value.length; + const lastColumn = renderColumnData.value[columnLength - 1]; + const scrollDistance = scrollRef.value!.scrollWidth - scrollRef.value!.clientWidth; + + // 拖动结束,最后一列对宽度做补偿 + if (offset > 0 && scrollDistance > 0 && offset > scrollDistance) { + offset = offset - scrollDistance; + lastColumn.width = Math.min( + lastColumn.maxWidth as number, + Math.max(lastColumn.minWidth as number, (lastColumn.width as number) + offset) + ); + } else if (scrollDistance <= 0 && lastColumn.$columnId !== columnId) { + lastColumn.width = Math.min( + lastColumn.maxWidth as number, + Math.max(lastColumn.minWidth as number, (lastColumn.width as number) + offset) + ); + } + // 重新计算总宽度 + let bodyTotalWidth = 0; + for (let i = 0; i < renderFixedLeftColumnData.value.length; i++) { + bodyTotalWidth += renderFixedLeftColumnData.value[i].width as number; + } + for (let i = 0; i < renderFixedRightColumnData.value.length; i++) { + bodyTotalWidth += renderFixedRightColumnData.value[i].width as number; + } + for (let i = 0; i < renderColumnData.value.length; i++) { + bodyTotalWidth += renderColumnData.value[i].width as number; + } + bodyContentWidth.value = bodyTotalWidth; + }; + + return { afterColumnDragend } +} \ No newline at end of file diff --git a/packages/devui-vue/devui/data-grid/src/composables/use-data-grid-scroll.ts b/packages/devui-vue/devui/data-grid/src/composables/use-data-grid-scroll.ts new file mode 100644 index 0000000000..7d1c3588b8 --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/composables/use-data-grid-scroll.ts @@ -0,0 +1,95 @@ +import { ref, onMounted, inject } from 'vue'; +import type { Ref } from 'vue'; +import { debounce } from 'lodash'; +import { DataGridInjectionKey } from '../data-grid-types'; +import type { InnerColumnConfig, InnerRowData, DataGridContext, ScrollYParams, ScrollXParams } from '../data-grid-types'; +import { getXStartOrEndIndex, getYStartIndex } from '../utils'; + +// 虚拟滚动 +export function useDataGridScroll() { + const virtualRowData = ref([]); + const virtualColumnData = ref([]); + const translateX = ref(0); + const translateY = ref(0); + + const calcVirtualColumnData = (scrollXParams: ScrollXParams, scrolling = true) => { + if (scrolling && scrollXParams.distance > scrollXParams.scrollScaleX[0] && scrollXParams.distance < scrollXParams.scrollScaleX[1]) { + return; + } + const startIndex = getXStartOrEndIndex(scrollXParams.originColumnData, scrollXParams.distance); + const endIndex = getXStartOrEndIndex(scrollXParams.originColumnData, scrollXParams.distance + scrollXParams.scrollViewWidth); + let upperStartIndex = Math.ceil(startIndex - scrollXParams.bufferSize); + upperStartIndex = upperStartIndex < 0 ? 0 : upperStartIndex; + translateX.value = scrollXParams.originColumnData[upperStartIndex].offsetLeft; + const upperList = scrollXParams.originColumnData.slice(upperStartIndex, startIndex); + const midList = scrollXParams.originColumnData.slice(startIndex, endIndex); + let downStartIndex = endIndex; + downStartIndex = downStartIndex > scrollXParams.totalColumn - 1 ? scrollXParams.totalColumn : downStartIndex; + scrollXParams.scrollScaleX = [ + scrollXParams.originColumnData[Math.floor(upperStartIndex + scrollXParams.bufferSize / 2)]?.offsetLeft || 0, + scrollXParams.originColumnData[Math.ceil(startIndex + scrollXParams.bufferSize / 2)]?.offsetLeft || 0 + ]; + const downList = scrollXParams.originColumnData.slice(downStartIndex, downStartIndex + scrollXParams.bufferSize); + virtualColumnData.value = [...upperList, ...midList, ...downList]; + let trTotalWidth = 0; + virtualColumnData.value.forEach((item) => { + trTotalWidth += item.width as number; + }) + }; + + const calcVirtualRowData = (scrollYParams: ScrollYParams) => { + if (scrollYParams.distance > scrollYParams.scrollScaleY[0] && scrollYParams.distance < scrollYParams.scrollScaleY[1]) { + return; + } + const startIndex = getYStartIndex(scrollYParams.originRowData, scrollYParams.distance); + let upperStartIndex = Math.ceil(startIndex - scrollYParams.renderCountPerScreen); + upperStartIndex = upperStartIndex < 0 ? 0 : upperStartIndex; + translateY.value = scrollYParams.originRowData[upperStartIndex].offsetTop!; + const upperList = scrollYParams.originRowData.slice(upperStartIndex, startIndex); + const midList = scrollYParams.originRowData.slice(startIndex, startIndex + scrollYParams.renderCountPerScreen); + let downStartIndex = Math.floor(startIndex + scrollYParams.renderCountPerScreen); + downStartIndex = downStartIndex > scrollYParams.originRowData.length - 1 ? scrollYParams.originRowData.length : downStartIndex; + scrollYParams.scrollScaleY = [ + scrollYParams.originRowData[Math.floor(upperStartIndex + scrollYParams.renderCountPerScreen / 2)]?.offsetTop! ?? 0, + scrollYParams.originRowData[Math.ceil(startIndex + scrollYParams.renderCountPerScreen / 2)]?.offsetTop! ?? 0 + ]; + const downList = scrollYParams.originRowData.slice(downStartIndex, downStartIndex + scrollYParams.renderCountPerScreen); + virtualRowData.value = [...upperList, ...midList, ...downList]; + } + + const resetVirtualRowData = () => { + virtualRowData.value = [] + } + + return { + translateX, + translateY, + virtualColumnData, + virtualRowData, + calcVirtualRowData, + calcVirtualColumnData, + resetVirtualRowData + } +} + +// 懒加载 +export function useDataGridLazy(scrollRef: Ref) { + const { lazy, rootCtx } = inject(DataGridInjectionKey) as DataGridContext; + const emitLazyThreshold = 40; + + const onScroll = debounce((e: Event) => { + const targetEl = e.target as HTMLElement; + const clientHeight = targetEl.clientHeight; + const scrollTop = targetEl.scrollTop; + const scrollHeight = targetEl.scrollHeight; + if (scrollHeight - scrollTop - clientHeight <= emitLazyThreshold) { + rootCtx.emit('loadMore') + } + }, 300); + + onMounted(() => { + if (lazy.value && scrollRef.value) { + scrollRef.value.addEventListener('scroll', onScroll); + } + }) +} \ No newline at end of file diff --git a/packages/devui-vue/devui/data-grid/src/composables/use-data-grid-tree.ts b/packages/devui-vue/devui/data-grid/src/composables/use-data-grid-tree.ts new file mode 100644 index 0000000000..bdd124fa88 --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/composables/use-data-grid-tree.ts @@ -0,0 +1,281 @@ +import { ref, toRefs } from 'vue'; +import type { SetupContext } from 'vue'; +import { isFunction } from 'lodash'; +import type { DataGridProps, InnerRowData, RowData, IExpandLoadMoreResult } from '../data-grid-types'; +import { generateInnerData } from '../utils'; + +export function useDataGridTree(props: DataGridProps, ctx: SetupContext, afterToggleExpandTree: () => void) { + const { data, checkableRelation, rowKey, reserveCheck } = toRefs(props); + const innerRowsData = ref([]); + const rowIndex = ref(0); + const allChecked = ref(false); + const halfAllChecked = ref(false); + const checkedRowsId = new Map>(); + const rowDataMap: Record = {}; + + const getInnerRowData = (node: InnerRowData | RowData) => { + let innerNode: InnerRowData | null = null; + if (node.$rowId) { + innerNode = node; + } else if (rowKey && rowKey.value) { + let key: string; + if (typeof rowKey.value === 'string') { + key = node[rowKey.value]; + } else if (isFunction(rowKey.value)) { + key = rowKey.value(node); + } else { + return null; + } + innerNode = rowDataMap[key]; + } + return innerNode; + }; + + const updateInnerRowsData = () => { + let allTrue = true; + let allFalse = true; + rowIndex.value = 0; + innerRowsData.value = generateInnerData(data.value, rowIndex, rowKey); + for (let i = 0; i < innerRowsData.value.length; i++) { + const item = innerRowsData.value[i]; + rowDataMap[item.$rowId!] = item; + if (reserveCheck.value && !Reflect.has(item, 'checked')) { + item.checked = checkedRowsId.get(item.$rowId)?.checked; + item.halfChecked = checkedRowsId.get(item.$rowId)?.halfChecked; + } + allTrue &&= Boolean(item.checked); + allFalse &&= Boolean(!item.checked); + } + allChecked.value = allTrue; + halfAllChecked.value = !(allTrue || allFalse); + }; + + const getShowRowsData = () => { + const result: InnerRowData[] = []; + for (let i = 0; i < innerRowsData.value.length; i++) { + if (innerRowsData.value[i].showNode) { + result.push(innerRowsData.value[i]); + } + } + return result; + }; + + // 切换子节点的展开收起状态 + const toggleChildNodeVisible = (node: InnerRowData) => { + if (!node.childList?.length) { + return; + } + const nodeList = [...node.childList]; + while (nodeList.length) { + const item = nodeList.shift(); + if (item) { + item.showNode = node.$expand; + if ((node.$expand && item.$expand) || (!node.$expand && item.childList?.length)) { + const temp = item.childList || []; + nodeList.push(...temp); + } + } + } + } + + // 树表格异步加载子节点后,更新其他行数据$rowIndex + const updateAfterRowIndex = (startIndex: number, addedLength: number) => { + for (let i = startIndex; i < innerRowsData.value.length; i++) { + innerRowsData.value[i].$rowIndex! += addedLength; + } + }; + + // 树表格展开懒加载回调 + const dealChildNodes = (result: IExpandLoadMoreResult) => { + const { node, rowItems } = result; + const tempRowIndex = ref(node.$rowIndex! + 1); + const childList = generateInnerData(rowItems, tempRowIndex, rowKey, node.$level, node); + updateAfterRowIndex(node.$rowIndex! + 1, childList.length); + innerRowsData.value.splice(node.$rowIndex! + 1, 0, ...childList); + // 更新childList + for (let i = 0; i < childList.length; i++) { + if (childList[i].$parentId === node.$rowId) { + node.childList?.push(childList[i]); + } + } + // 更新子节点状态 + toggleChildNodeVisible(node); + afterToggleExpandTree(); + }; + + // 切换树表格单行展开收起状态,指定status可设置展开收起状态 + const toggleRowExpansion = (node: InnerRowData, status?: boolean) => { + // 为了兼容业务通过原始行数据调用此方法,所以需要通过getInnerRowData方法获取内部处理后的行数据 + const innerNode: InnerRowData | null = getInnerRowData(node); + if (!innerNode) { + return; + } + if (typeof status === 'boolean') { + innerNode.$expand = status; + } else { + innerNode.$expand = !innerNode.$expand; + } + if (!innerNode.isLeaf && !innerNode.childList?.length) { + ctx.emit('expandLoadMore', innerNode, dealChildNodes); + } else { + toggleChildNodeVisible(innerNode); + afterToggleExpandTree(); + } + ctx.emit('expandChange', innerNode.$expand, innerNode); + }; + + // 切换树表格所有行展开收起状态,指定status可设置展开收起状态 + const toggleAllRowExpansion = (status?: boolean) => { + for (let i = 0; i < innerRowsData.value.length; i++) { + const item = innerRowsData.value[i]; + if (typeof status === 'boolean') { + item.$expand = status; + } else { + item.$expand = !item.$expand; + } + if (item.$level !== 1) { + item.showNode = item.$expand; + } + } + afterToggleExpandTree(); + ctx.emit('expandAllChange', innerRowsData.value[0].$expand); + }; + + // 保留勾选状态时,更新已勾选节点 + const updateCheckedRowsId = (node: InnerRowData) => { + if (reserveCheck.value) { + if (node.checked) { + checkedRowsId.set(node.$rowId, { checked: node.checked, halfChecked: Boolean(node.halfChecked) }); + } else { + checkedRowsId.delete(node.$rowId); + } + } + }; + + // 父子勾选联动,改变子节点勾选状态 + const toggleChildNodeChecked = (node: InnerRowData) => { + if (!node.childList?.length) { + return; + } + node.halfChecked = false; + const nodeList = [...node.childList]; + while (nodeList.length) { + const item = nodeList.shift(); + if (item) { + if (!item.disableCheck) { + item.checked = node.checked; + item.halfChecked = false; + updateCheckedRowsId(item); + } + const temp = item.childList || []; + nodeList.push(...temp); + } + } + }; + + // 父子勾选联动,改变父节点勾选状态 + const toggleParentNodeChecked = (node: InnerRowData) => { + if (!node.$parentId) { + return; + } + const parentNode = rowDataMap[node.$parentId]; + if (!parentNode || parentNode.disableCheck) { + return; + } + // 父节点勾选状态和半选状态需要根据是否有其他后代节点仍被选中而确定 + const descendantCheckedNodes = parentNode.descendantList?.filter((item) => item.checked) || []; + // 子节点选中,父节点也选中 + if (node.checked) { + parentNode.checked = true; + } else { + // 子节点全部取消选中 + if (descendantCheckedNodes.length === 0) { + parentNode.checked = false; + } else { + parentNode.checked = true; + } + } + parentNode.halfChecked = descendantCheckedNodes.length !== 0 && parentNode.descendantList?.length !== descendantCheckedNodes.length; + updateCheckedRowsId(parentNode); + if (parentNode.$parentId) { + toggleParentNodeChecked(parentNode); + } + }; + + // 行勾选状态变更时,更新表头全选的勾选状态 + const updateCheckAll = () => { + let allTrue = true; + let allFalse = true; + + for (let i = 0; i < innerRowsData.value.length; i++) { + allTrue &&= Boolean(innerRowsData.value[i].checked); + allFalse &&= Boolean(!innerRowsData.value[i].checked); + } + + allChecked.value = allTrue; + halfAllChecked.value = !(allTrue || allFalse); + }; + + // 切换行的勾选状态,指定status可设置勾选状态 + const toggleRowChecked = (node: InnerRowData | RowData, status?: boolean) => { + const innerNode: InnerRowData | null = getInnerRowData(node); + if (!innerNode || innerNode.disableCheck) { + return; + } + if (typeof status === 'boolean') { + innerNode.checked = status; + } else { + innerNode.checked = !innerNode.checked; + } + updateCheckedRowsId(innerNode); + if (['downward', 'both'].includes(checkableRelation.value)) { + toggleChildNodeChecked(innerNode); + } + if (['upward', 'both'].includes(checkableRelation.value)) { + toggleParentNodeChecked(innerNode); + } + updateCheckAll(); + ctx.emit('checkChange', innerNode.checked, innerNode); + }; + + // 切换全选的勾选状态,指定status可设置勾选状态 + const toggleAllRowChecked = (status?: boolean) => { + if (typeof status === 'boolean') { + allChecked.value = status; + } else { + allChecked.value = !allChecked.value; + } + halfAllChecked.value = false; + for (let i = 0; i < innerRowsData.value.length; i++) { + if (!innerRowsData.value[i].disableCheck) { + innerRowsData.value[i].checked = allChecked.value; + innerRowsData.value[i].halfChecked = false; + updateCheckedRowsId(innerRowsData.value[i]); + } + } + ctx.emit('checkAllChange', allChecked.value); + }; + + // 获取当前选中的行数据 + const getCheckedRows = () => { + const result: InnerRowData[] = []; + for (let i = 0; i < innerRowsData.value.length; i++) { + if (innerRowsData.value[i].checked) { + result.push({ ...innerRowsData.value[i] }) + } + } + return result; + }; + + return { + allChecked, + halfAllChecked, + updateInnerRowsData, + getShowRowsData, + toggleRowExpansion, + toggleAllRowExpansion, + toggleRowChecked, + toggleAllRowChecked, + getCheckedRows, + }; +} \ No newline at end of file diff --git a/packages/devui-vue/devui/data-grid/src/composables/use-data-grid.ts b/packages/devui-vue/devui/data-grid/src/composables/use-data-grid.ts new file mode 100644 index 0000000000..3b40bb0c03 --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/composables/use-data-grid.ts @@ -0,0 +1,350 @@ +import { toRefs, computed, watch, ref, nextTick, onMounted, onBeforeMount } from 'vue'; +import type { SetupContext, Ref } from 'vue'; +import { useNamespace } from '../../../shared/hooks/use-namespace'; +import { useDataGridScroll } from './use-data-grid-scroll'; +import { useColumnSort } from './use-column-sort'; +import { useDataGridTree } from './use-data-grid-tree'; +import { useDataGridColumnDrag } from './use-data-grid-drag'; +import type { DataGridProps, InnerRowData, InnerColumnConfig, ScrollYParams, ScrollXParams } from '../data-grid-types'; +import { ColumnType, RowHeightMap } from '../const'; +import { calcEachColumnWidth } from '../utils'; + +export function useDataGrid(props: DataGridProps, ctx: SetupContext) { + const { data, columns, size, virtualScroll } = toRefs(props); + const scrollRef = ref(); + const headBoxRef = ref(); + const bodyContentWidth = ref(0); + const bodyContentHeight = ref(0); + const bodyScrollLeft = ref(0); + const isTreeGrid = ref(false); + const renderFixedLeftColumnData = ref([]); + const renderFixedRightColumnData = ref([]); + const renderRowData = ref([]); + const renderColumnData = ref([]); + const sliceData = computed(() => data.value.slice()); + const sliceColumns = computed(() => columns.value.slice()); + const rowHeight = RowHeightMap[size.value]; + let tick = false; + let resizeObserver: ResizeObserver; + const scrollYParams: ScrollYParams = { + distance: 0, + renderCountPerScreen: 0, + scrollScaleY: [0, 0], + originRowData: [], + defaultSortRowData: [], + }; + const scrollXParams: ScrollXParams = { + distance: 0, + totalColumn: 0, + bufferSize: 5, + scrollViewWidth: 0, + scrollScaleX: [0, 0], + originColumnData: [] + }; + const { translateX, translateY, virtualColumnData, virtualRowData, calcVirtualRowData, calcVirtualColumnData, resetVirtualRowData } = + useDataGridScroll(); + const { sort, execSortMethod, addGridThContextToMap, clearAllSortState } = useColumnSort(scrollYParams, afterSort); + const { + allChecked, + halfAllChecked, + updateInnerRowsData, + getShowRowsData, + toggleRowExpansion, + toggleAllRowExpansion, + toggleRowChecked, + toggleAllRowChecked, + getCheckedRows, + } = useDataGridTree(props, ctx, afterToggleExpandTree); + const { afterColumnDragend } = useDataGridColumnDrag( + bodyContentWidth, + scrollRef, + renderFixedLeftColumnData, + renderFixedRightColumnData, + renderColumnData + ); + + const initOriginRowData = () => { + let bodyTotalHeight = 0; + const rowsData = getShowRowsData(); + scrollYParams.originRowData = []; + for (let i = 0; i < rowsData.length; i++) { + const itemRow = rowsData[i]; + itemRow.height = rowHeight; + itemRow.offsetTop = bodyTotalHeight; + scrollYParams.originRowData.push(itemRow); + bodyTotalHeight += rowHeight; + if (!isTreeGrid.value) { + isTreeGrid.value = !itemRow.isLeaf; + } + } + if (!virtualScroll.value) { + renderRowData.value = scrollYParams.originRowData; + } + scrollYParams.defaultSortRowData = scrollYParams.originRowData; + bodyContentHeight.value = bodyTotalHeight; + }; + const initVirtualRowData = (distance = 0) => { + scrollYParams.distance = distance; + scrollYParams.renderCountPerScreen = Math.ceil(scrollRef.value!.clientHeight / rowHeight); + scrollYParams.scrollScaleY = [0, scrollYParams.renderCountPerScreen * rowHeight]; + calcVirtualRowData(scrollYParams); + }; + + const initOriginColumnData = () => { + let bodyTotalWidth = 0; + let columnId = 0; + const scrollViewWidth = scrollRef.value?.clientWidth || 0; + scrollXParams.totalColumn = columns.value.length; + renderFixedLeftColumnData.value = []; + renderFixedRightColumnData.value = []; + scrollXParams.originColumnData = []; + const columnsWithRealWidth = calcEachColumnWidth(columns.value, scrollViewWidth); + for (let i = 0; i < scrollXParams.totalColumn; i++) { + const itemColumn: InnerColumnConfig = { + ...columnsWithRealWidth[i], + offsetLeft: bodyTotalWidth, + $columnId: `columnId-${columnId++}`, + }; + const prevColumn = i > 0 ? columnsWithRealWidth[i - 1] : null; + if (prevColumn) { + if (prevColumn.type && ColumnType.includes(prevColumn.type) && !itemColumn.type) { + itemColumn.$showExpandTreeIcon = true; + } + } else { + if (!itemColumn.type) { + itemColumn.$showExpandTreeIcon = true; + } + } + + if (itemColumn.fixed === 'left') { + renderFixedLeftColumnData.value.push(itemColumn); + } else if (itemColumn.fixed === 'right') { + renderFixedRightColumnData.value.push(itemColumn); + } else { + scrollXParams.originColumnData.push(itemColumn); + } + bodyTotalWidth += itemColumn.width as number; + } + if (!virtualScroll.value) { + renderColumnData.value = scrollXParams.originColumnData; + translateX.value = renderColumnData.value[0]?.offsetLeft ?? 0; + } + bodyContentWidth.value = bodyTotalWidth; + }; + const initVirtualColumnData = (distance = 0, scrollViewWidth: number) => { + scrollXParams.distance = distance; + scrollXParams.scrollViewWidth = scrollViewWidth; + scrollXParams.scrollScaleX = [0, scrollRef.value!.clientWidth]; + calcVirtualColumnData(scrollXParams, false); + } + + function afterSort() { + scrollYParams.scrollScaleY = [0, 0]; + if (!virtualScroll.value) { + renderRowData.value = scrollYParams.originRowData; + } else { + calcVirtualRowData(scrollYParams); + } + } + + function afterToggleExpandTree() { + initOriginRowData(); + scrollYParams.scrollScaleY = [0, 0]; + virtualScroll.value && calcVirtualRowData(scrollYParams); + } + + function refreshRowsData() { + let distance = 0; + updateInnerRowsData(); + initOriginRowData(); + nextTick(() => { + if (virtualScroll.value && scrollRef.value && scrollYParams.originRowData.length) { + const scrollTop = scrollRef.value.scrollTop; + distance = scrollTop > scrollYParams.originRowData[scrollYParams.originRowData.length - 1].offsetTop! ? 0 : scrollTop; + initVirtualRowData(distance); + } else { + resetVirtualRowData(); + } + }); + } + + watch( + sliceData, + () => { + refreshRowsData(); + }, + { immediate: true } + ); + watch( + sliceColumns, + () => { + if (!sliceColumns.value.length) { + renderColumnData.value = []; + return; + } + let distance = 0; + nextTick(() => { + initOriginColumnData(); + if (virtualScroll.value && scrollRef.value) { + distance = scrollRef.value.scrollLeft; + initVirtualColumnData(distance, scrollRef.value.clientWidth); + } + }); + }, + { immediate: true } + ); + + watch( + virtualRowData, + (val: InnerRowData[]) => { + if (virtualScroll.value) { + renderRowData.value = val; + } + }, + { immediate: true } + ); + + watch( + virtualColumnData, + (val: InnerColumnConfig[]) => { + if (virtualScroll.value) { + renderColumnData.value = val; + } + }, + { immediate: true } + ); + + const onScroll = (e: Event) => { + if (tick) { + return; + } + tick = true; + requestAnimationFrame(() => { + tick = false; + }); + const scrollLeft = (e.target as HTMLElement).scrollLeft; + const scrollTop = (e.target as HTMLElement).scrollTop; + if (scrollLeft !== scrollXParams.distance) { + headBoxRef.value && (headBoxRef.value.scrollLeft = scrollLeft); + bodyScrollLeft.value = scrollLeft; + if (scrollXParams.originColumnData.length === 0) { + return; + } + scrollXParams.distance = scrollLeft; + virtualScroll.value && calcVirtualColumnData(scrollXParams); + } else if (scrollTop !== scrollYParams.distance) { + if (scrollYParams.originRowData.length === 0) { + return; + } + scrollYParams.distance = scrollTop; + virtualScroll.value && calcVirtualRowData(scrollYParams); + } + }; + + onMounted(() => { + scrollRef.value?.addEventListener('scroll', onScroll); + if (typeof window !== 'undefined' && scrollRef.value) { + resizeObserver = new ResizeObserver(() => { + if (scrollRef.value) { + let distance = 0; + initOriginColumnData(); + distance = scrollRef.value!.scrollLeft; + virtualScroll.value && initVirtualColumnData(distance, scrollRef.value!.clientWidth); + } + }); + resizeObserver.observe(scrollRef.value); + } + }); + + onBeforeMount(() => { + resizeObserver?.disconnect(); + }); + + return { + scrollRef, + headBoxRef, + bodyScrollLeft, + bodyContentHeight, + bodyContentWidth, + translateX, + translateY, + renderFixedLeftColumnData, + renderFixedRightColumnData, + renderColumnData, + renderRowData, + isTreeGrid, + allChecked, + halfAllChecked, + sort, + getCheckedRows, + execSortMethod, + addGridThContextToMap, + clearAllSortState, + toggleRowExpansion, + toggleAllRowExpansion, + toggleRowChecked, + toggleAllRowChecked, + afterColumnDragend, + refreshRowsData, + } +} + +export function useDataGridStyle(props: DataGridProps, scrollRef: Ref) { + const ns = useNamespace('data-grid'); + const { striped, rowHoveredHighlight, fixHeader, headerBg, borderType, shadowType, virtualScroll } = toRefs(props); + const scrollPosition = ref('left'); + const sliceColumns = computed(() => props.columns.slice()); + + const gridClasses = computed(() => ({ + [ns.b()]: true, + [ns.m('fix-header')]: fixHeader.value, + [ns.m('striped')]: striped.value, + [ns.m('row-hover-highlight')]: rowHoveredHighlight.value, + [ns.m('header-bg')]: headerBg.value, + [ns.m(borderType.value)]: Boolean(borderType.value), + [ns.m(shadowType.value)]: Boolean(shadowType.value), + [ns.m('is-virtual')]: Boolean(virtualScroll.value), + [ns.m(`scroll-${scrollPosition.value}`)]: Boolean(scrollPosition.value), + })); + + const onScroll = (e: Event) => { + const target = e.target as HTMLElement; + const scrollLeft = target.scrollLeft; + if (scrollLeft === 0) { + if (target.clientWidth === target.scrollWidth) { + scrollPosition.value = ''; + } else { + scrollPosition.value = 'left'; + } + } else if (scrollLeft + target.clientWidth === target.scrollWidth) { + scrollPosition.value = 'right'; + } else { + scrollPosition.value = 'middle'; + } + }; + + const initScrollPosition = () => { + scrollPosition.value = scrollRef.value!.clientWidth === scrollRef.value!.clientWidth ? '' : 'left'; + }; + + watch( + sliceColumns, + () => { + if (scrollRef.value) { + // 等待列渲染完成再判断是否有滚动条 + setTimeout(initScrollPosition); + } + }, + { flush: 'post' } + ); + + onMounted(() => { + if (scrollRef.value) { + scrollRef.value.addEventListener('scroll', onScroll); + // 等待列渲染完成再判断是否有滚动条 + setTimeout(initScrollPosition); + } + }); + + return { gridClasses }; +} \ No newline at end of file diff --git a/packages/devui-vue/devui/data-grid/src/composables/use-grid-th.ts b/packages/devui-vue/devui/data-grid/src/composables/use-grid-th.ts new file mode 100644 index 0000000000..bf90f25e8b --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/composables/use-grid-th.ts @@ -0,0 +1,168 @@ +import { ref, inject, computed, watch } from 'vue'; +import type { Ref, SetupContext } from 'vue'; +import { DataGridInjectionKey } from '../data-grid-types'; +import type { InnerColumnConfig, SortDirection, DataGridContext, FilterListItem, GridThFilterProps } from '../data-grid-types'; + +export function useGridThSort(columnConfig: Ref) { + const { rootCtx, execSortMethod, clearAllSortState } = inject(DataGridInjectionKey) as DataGridContext; + const directionMap: Record<'asc' | 'desc' | 'default', SortDirection> = { + asc: 'desc', + desc: '', + default: 'asc' + }; + const direction = ref(''); + + const doSort = (directionVal: SortDirection) => { + if (direction.value === directionVal) { + return; + } + clearAllSortState(); + direction.value = directionVal; + execSortMethod(direction.value, columnConfig.value.sortMethod); + rootCtx.emit('sortChange', { field: columnConfig.value.field, direction: direction.value }); + }; + + const onSortClick = () => { + doSort(directionMap[direction.value || 'default']); + }; + + const doClearSort = () => { + direction.value = ''; + }; + + return { direction, doSort, onSortClick, doClearSort }; +} + +export function useGridThFilter(columnConfig: Ref) { + const filterActive = ref(false); + const onFilterChange = (e: FilterListItem | FilterListItem[]) => { + filterActive.value = Array.isArray(e) ? Boolean(e.length) : Boolean(e); + columnConfig.value.filterChange?.(e); + }; + const setFilterStatus = (status: boolean) => { + filterActive.value = status; + }; + + return { filterActive, setFilterStatus, onFilterChange }; +} + +export function useGridThMultipleFilter(props: GridThFilterProps, ctx: SetupContext) { + const _checkList = ref([]); + const _checkAllRecord = ref(false); + const _halfChecked = ref(false); + const filterListTemp = computed(() => props.filterList?.slice()); + const _checkAll = computed({ + get: () => _checkAllRecord.value, + set: (val: boolean) => { + _checkAllRecord.value = val; + for (let i = 0; i < _checkList.value.length; i++) { + _checkList.value[i].checked = val; + } + } + }); + + watch( + filterListTemp, + () => { + props.filterList?.forEach((item) => { + _checkList.value.push({ checked: false, ...item }); + }); + }, + { immediate: true } + ); + + const updateCheckAll = () => { + if (!_checkList.value.length) { + return; + } + + let allTrue = true; + let allFalse = true; + + for (let i = 0; i < _checkList.value.length; i++) { + allTrue &&= Boolean(_checkList.value[i].checked); + allFalse &&= Boolean(!_checkList.value[i].checked); + } + + _checkAllRecord.value = allTrue; + _halfChecked.value = !(allFalse || allTrue); + }; + + const getCheckedItems = () => _checkList.value.filter((item) => item.checked); + + const onCheckAllClick = () => { + _checkAll.value = !_checkAll.value; + }; + + const onItemClick = (item: FilterListItem) => { + item.checked = !item.checked; + updateCheckAll(); + }; + + const onConfirm = () => { + ctx.emit('confirm', getCheckedItems()); + }; + + return { _checkList, _checkAll, _halfChecked, onCheckAllClick, onItemClick, updateCheckAll, onConfirm }; +} + +export function useGridThDrag(columnConfig: Ref) { + const { fixHeader, rootRef, rootCtx, scrollRef, afterColumnDragend } = inject(DataGridInjectionKey) as DataGridContext; + const resizing = ref(false); + const thRef = ref(); + let initialWidth = 0; + let mouseDownScreenX = 0; + let resizeBarElement: HTMLElement; + + const onMousemove = (e: MouseEvent) => { + const movementX = e.clientX - mouseDownScreenX; + const newWidth = initialWidth + movementX; + const finalWidth = Math.min(columnConfig.value.maxWidth as number, Math.max(columnConfig.value.minWidth as number, newWidth)); + if (resizeBarElement && scrollRef.value) { + resizeBarElement.style.left = `${finalWidth + thRef.value.offsetLeft - (fixHeader.value ? scrollRef.value.scrollLeft : 0)}px`; + } + rootCtx.emit('resizing', { field: columnConfig.value.field, width: finalWidth }); + }; + + const onMouseup = (e: MouseEvent) => { + const movementX = e.clientX - mouseDownScreenX; + const newWidth = initialWidth + movementX; + const finalWidth = Math.min(columnConfig.value.maxWidth as number, Math.max(columnConfig.value.minWidth as number, newWidth)); + columnConfig.value.width = finalWidth; + resizing.value = false; + rootRef.value?.children[0].classList.remove('data-grid-selector'); + rootRef.value?.children[0].removeChild(resizeBarElement); + afterColumnDragend(columnConfig.value.$columnId, initialWidth - finalWidth); + rootCtx.emit('resizeEnd', { field: columnConfig.value.field, width: finalWidth, beforeWidth: initialWidth }); + document.removeEventListener('mouseup', onMouseup); + document.removeEventListener('mousemove', onMousemove); + }; + + const onMousedown = (e: MouseEvent) => { + const isHandle = (e.target as HTMLElement).classList.contains('resize-handle'); + if (isHandle && rootRef.value && scrollRef.value) { + rootCtx.emit('resizeStart', columnConfig.value.field); + const initialOffset = thRef.value.offsetLeft; + initialWidth = thRef.value.offsetWidth as number; + mouseDownScreenX = e.clientX; + e.stopPropagation(); + resizing.value = true; + + rootRef.value.children[0].classList.add('data-grid-selector'); + + resizeBarElement = document.createElement('div'); + resizeBarElement.classList.add('resize-bar'); + if (rootRef.value.children[0]) { + resizeBarElement.style.display = 'block'; + resizeBarElement.style.left = initialOffset + initialWidth - (fixHeader.value ? scrollRef.value.scrollLeft + 2 : 2) + 'px'; + rootRef.value.children[0].appendChild(resizeBarElement); + } + + document.addEventListener('mouseup', onMouseup); + + document.addEventListener('mousemove', onMousemove); + } + }; + + return { thRef, resizing, onMousedown }; +} \ No newline at end of file diff --git a/packages/devui-vue/devui/data-grid/src/composables/use-overflow-tooltip.ts b/packages/devui-vue/devui/data-grid/src/composables/use-overflow-tooltip.ts new file mode 100644 index 0000000000..b61198f057 --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/composables/use-overflow-tooltip.ts @@ -0,0 +1,90 @@ +import { ref } from 'vue'; +import { debounce } from 'lodash'; +import type { Placement } from '../../../overlay'; +import type { InnerColumnConfig } from '../data-grid-types'; + +export function useOverflowTooltip() { + let tdElement: HTMLElement; + const originRef = ref(); + const showTooltip = ref(false); + const tooltipContent = ref(''); + const tooltipPosition = ref(['top', 'right', 'bottom', 'left']); + const tooltipClassName = ref(''); + let mouseEnterDelay = 150; + let mouseLeaveDelay = 100; + let enterable = true; + let isEnterOverlay = false; + let tooltipConfigContent: string | undefined; + + function shouldShowTooltip() { + const range = document.createRange(); + range.setStart(tdElement, 0); + range.setEnd(tdElement, tdElement.childNodes.length); + const rangeWidth = range.getBoundingClientRect().width; + const padding = + parseInt(window.getComputedStyle(tdElement)['paddingLeft'], 10) + parseInt(window.getComputedStyle(tdElement)['paddingRight'], 10); + return Boolean(rangeWidth + padding > tdElement.offsetWidth); + } + + const enter = debounce((tdElement: HTMLElement) => { + if (!isEnterOverlay && shouldShowTooltip() && tdElement.classList.contains('mouse-enter')) { + showTooltip.value = true; + originRef.value = tdElement; + tooltipContent.value = tooltipConfigContent ?? (tdElement?.innerText || tdElement?.textContent || ''); + } + }, mouseEnterDelay); + + const leave = debounce(() => { + if (!isEnterOverlay) { + showTooltip.value = false; + tooltipContent.value = ''; + originRef.value = undefined; + } + }, mouseLeaveDelay); + + const onCellMouseenter = (e: Event, tooltipConfig: InnerColumnConfig['showOverflowTooltip']) => { + tdElement = e.currentTarget as HTMLElement; + if (tooltipConfig && tdElement) { + tdElement.classList.add('mouse-enter'); + if (typeof tooltipConfig !== 'boolean') { + tooltipConfigContent = tooltipConfig.content; + tooltipConfig.position && (tooltipPosition.value = tooltipConfig.position); + tooltipConfig.class && (tooltipClassName.value = tooltipConfig.class); + mouseEnterDelay = tooltipConfig.mouseEnterDelay ?? mouseEnterDelay; + enterable = tooltipConfig.enterable ?? enterable; + } + enter(tdElement); + } + }; + + const onCellMouseleave = (e: Event, tooltipConfig: InnerColumnConfig['showOverflowTooltip']) => { + tdElement = e.currentTarget as HTMLElement; + if (tooltipConfig && tdElement) { + tdElement.classList.remove('mouse-enter'); + leave(); + } + }; + + const onOverlayMouseenter = () => { + if (enterable) { + isEnterOverlay = true; + } + }; + + const onOverlayMouseleave = () => { + isEnterOverlay = false; + leave(); + }; + + return { + showTooltip, + originRef, + tooltipContent, + tooltipPosition, + tooltipClassName, + onCellMouseenter, + onCellMouseleave, + onOverlayMouseenter, + onOverlayMouseleave + }; +} \ No newline at end of file diff --git a/packages/devui-vue/devui/data-grid/src/const.ts b/packages/devui-vue/devui/data-grid/src/const.ts new file mode 100644 index 0000000000..3479a6f8cd --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/const.ts @@ -0,0 +1,13 @@ +import type { Size } from "./data-grid-types"; + +export const ToggleTreeIcon = 'toggle-tree-icon'; +export const DataGridCheckboxClass = 'data-grid-checkbox'; +export const ColumnType = ['checkable', 'index']; +export const ColumnMinWidth = 80; +export const RowHeightMap: Record = { + mini: 24, + xs: 30, + sm: 42, + md: 46, + lg: 54 +} \ No newline at end of file diff --git a/packages/devui-vue/devui/data-grid/src/data-grid-types.ts b/packages/devui-vue/devui/data-grid/src/data-grid-types.ts new file mode 100644 index 0000000000..482f5678cc --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/data-grid-types.ts @@ -0,0 +1,333 @@ +import type { PropType, ExtractPropTypes, VNode, InjectionKey, Ref, SetupContext } from 'vue'; +import type { Placement } from '../../overlay'; + +export interface RowData { + checked?: boolean; + disableCheck?: boolean; + children?: RowData[]; + isLeaf?: boolean; + [k: string]: any; +} +export type ColumnType = 'checkable' | 'index' | ''; +export type ColumnAlign = 'left' | 'center' | 'right'; +export type BorderType = '' | 'bordered' | 'borderless'; +export type Size = 'mini' | 'xs' | 'sm' | 'md' | 'lg'; +export type ShadowType = '' | 'shadowed'; +export type FixedDirection = 'left' | 'right'; +export type SortDirection = 'asc' | 'desc' | ''; +export type CheckableRelation = 'upward' | 'downward' | 'both' | 'none'; +export type SortMethod = (a: T, b: T) => boolean; +export type RowClass = string | ((row: RowData, rowIndex: number) => string); +export type CellClass = string | ((row: RowData, rowIndex: number, column: ColumnConfig, columnIndex: number) => string); +export type RowKey = string | ((row: RowData) => string); +export interface TooltipConfig { + content?: string; + position?: Placement[]; + mouseEnterDelay?: number; + enterable?: boolean; + class?: string; +} +export interface FilterListItem { + name: string; + value: any; + checked?: boolean; +} +export interface ColumnConfig { + header: string; + field: string; + width?: number | string; + minWidth?: number | string; + maxWidth?: number | string; + type?: ColumnType; + resizable?: boolean; + sortable?: boolean; + showSortIcon?: boolean; + sortMethod?: SortMethod; + filterable?: boolean; + showFilterIcon?: boolean; + filterMultiple?: boolean; + filterList?: FilterListItem[]; + filterChange?: (val: FilterListItem | FilterListItem[]) => void; + filterMenu?: (scope: { toggleFilterMenu: (status?: boolean) => void; setFilterStatus: (status: boolean) => void }) => VNode; + fixed?: FixedDirection; + align?: ColumnAlign; + showOverflowTooltip?: boolean | TooltipConfig; + showHeadOverflowTooltip?: boolean | TooltipConfig; + headRender?: (columnConfig: ColumnConfig) => VNode; + cellRender?: (rowData: RowData, rowIndex: number, cellData: string, cellIndex: number) => VNode; +} +export interface InnerColumnConfig extends ColumnConfig { + $columnId: string; + offsetLeft: number; + $showExpandTreeIcon?: boolean; +} +export interface InnerRowData extends RowData { + $rowId?: string; + $parentId?: string; + $rowIndex?: number; + $level?: number; + height?: number; + offsetTop?: number; + showNode?: boolean; // 树表格时,控制是否展示子节点 + $expand?: boolean; + halfChecked?: boolean; + childList?: InnerRowData[]; + descendantList?: InnerRowData[]; +} +export interface ScrollYParams { + distance: number; + renderCountPerScreen: number; + scrollScaleY: number[]; + originRowData: InnerRowData[]; + defaultSortRowData: InnerRowData[]; +} +export interface ScrollXParams { + distance: number; + totalColumn: number; + bufferSize: number; + scrollViewWidth: number; + scrollScaleX: number[]; + originColumnData: InnerColumnConfig[]; +} +export interface IExpandLoadMoreResult { + node: InnerRowData; + rowItems: RowData[]; +} + +export const dataGridProps = { + columns: { + type: Array as PropType, + default: () => [] + }, + data: { + type: Array as PropType, + default: () => [] + }, + indent: { + type: Number, + default: 16 + }, + striped: { + type: Boolean, + default: false + }, + fixHeader: { + type: Boolean, + default: false + }, + rowHoveredHighlight: { + type: Boolean, + default: true + }, + headerBg: { + type: Boolean, + default: false + }, + showHeader: { + type: Boolean, + default: true + }, + lazy: { + type: Boolean, + default: false + }, + virtualScroll: { + type: Boolean, + default: false + }, + reserveCheck: { + type: Boolean, + default: false + }, + resizable: { + type: Boolean, + }, + rowClass: { + type: [String, Function] as PropType, + default: '' + }, + rowKey: { + type: [String, Function] as PropType, + }, + cellClass: { + type: [String, Function] as PropType, + default: '' + }, + size: { + type: String as PropType, + default: 'sm' + }, + borderType: { + type: String as PropType, + default: '' + }, + shadowType: { + type: String as PropType, + default: '' + }, + checkableRelation: { + type: String as PropType, + default: 'both' + } +} +export type DataGridProps = ExtractPropTypes; + +export interface DataGridContext { + showHeader: Ref; + fixHeader: Ref; + lazy: Ref; + virtualScroll: Ref; + resizable: Ref; + indent: Ref; + bodyContentWidth: Ref; + bodyContentHeight: Ref; + translateX: Ref; + translateY: Ref; + bodyScrollLeft: Ref; + rowClass: Ref; + cellClass: Ref; + size: Ref; + rootRef: Ref; + scrollRef: Ref; + headBoxRef: Ref; + renderColumnData: Ref; + renderFixedLeftColumnData: Ref; + renderFixedRightColumnData: Ref; + renderRowData: Ref; + rootCtx: SetupContext; + allChecked: Ref; + halfAllChecked: Ref; + isTreeGrid: Ref; + execSortMethod: (direction: SortDirection, sortMethod?: SortMethod) => void; + addGridThContextToMap: (key: ColumnConfig['field'], thCtx: GridThContext) => void; + clearAllSortState: () => void; + toggleRowExpansion: (node: InnerRowData, status?: boolean) => void; + toggleRowChecked: (node: InnerRowData, status?: boolean) => void; + toggleAllRowChecked: (status?: boolean) => void; + afterColumnDragend: (columnId: InnerColumnConfig['$columnId'], offset: number) => void; +} +export const DataGridInjectionKey: InjectionKey = Symbol('d-data-grid'); + +export interface GridThContext { + doSort: (direction: SortDirection) => void; + doClearSort: () => void; +} + +export const gridHeadProps = { + columnData: { + type: Array as PropType, + default: () => [] + }, + leftColumnData: { + type: Array as PropType, + default: () => [] + }, + rightColumnData: { + type: Array as PropType, + default: () => [] + }, + translateX: { + type: Number, + default: 0 + }, + bodyScrollLeft: { + type: Number, + default: 0 + } +} +export type GridHeadProps = ExtractPropTypes; + +export const gridBodyProps = { + rowData: { + type: Array as PropType, + default: () => [] + }, + columnData: { + type: Array as PropType, + default: () => [] + }, + leftColumnData: { + type: Array as PropType, + default: () => [] + }, + rightColumnData: { + type: Array as PropType, + default: () => [] + }, + translateX: { + type: Number, + default: 0 + }, + translateY: { + type: Number, + default: 0 + }, + bodyScrollLeft: { + type: Number, + default: 0 + } +} +export type GridBodyProps = ExtractPropTypes; + +export const gridThProps = { + columnConfig: { + type: Object as PropType, + default: () => ({}) + }, + mouseenterCb: { + type: Function as PropType<(e: Event, tooltipConfig: InnerColumnConfig['showOverflowTooltip']) => void>, + default: () => ({}) + }, + mouseleaveCb: { + type: Function as PropType<(e: Event, tooltipConfig: InnerColumnConfig['showOverflowTooltip']) => void>, + default: () => ({}) + } +} +export type GridThProps = ExtractPropTypes; + +export const gridThFilterProps = { + filterList: { + type: Array as PropType, + default: () => [] + }, + multiple: { + type: Boolean, + default: true, + }, + showFilterIcon: { + type: Boolean, + default: false + }, + filterMenu: { + type: Function as PropType + }, + setFilterStatus: { + type: Function as PropType<(status: boolean) => void>, + default() { } + } +} +export type GridThFilterProps = ExtractPropTypes; + +export const gridTdProps = { + rowData: { + type: Object as PropType, + default: () => ({}) + }, + cellData: { + type: Object as PropType, + default: () => ({}) + }, + rowIndex: { + type: Number, + default: 0 + }, + mouseenterCb: { + type: Function as PropType<(e: Event, tooltipConfig: InnerColumnConfig['showOverflowTooltip']) => void>, + default: () => ({}) + }, + mouseleaveCb: { + type: Function as PropType<(e: Event, tooltipConfig: InnerColumnConfig['showOverflowTooltip']) => void>, + default: () => ({}) + } +} +export type GridTdProps = ExtractPropTypes; \ No newline at end of file diff --git a/packages/devui-vue/devui/data-grid/src/data-grid.scss b/packages/devui-vue/devui/data-grid/src/data-grid.scss new file mode 100644 index 0000000000..f7b217bfae --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/data-grid.scss @@ -0,0 +1,655 @@ +@import '@devui/theme/styles-var/devui-var.scss'; + +$devui-table-inset-shadow-left: var(--devui-table-inset-shadow-left, 8px 0 8px -4px); +$devui-table-inset-shadow-right: var(--devui-table-inset-shadow-right, -8px 0 8px -4px); + +@mixin overflow-ellipsis { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +@mixin resize-handle-arrow { + content: ""; + position: absolute; + top: 50%; + display: block; + width: 0; + height: 0; + border: 5px solid transparent; + transform: translateY(-50%); + pointer-events: none; +} + +.#{$devui-prefix}-data-grid { + + &, + & * { + box-sizing: border-box; + + &::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + &::-webkit-scrollbar-track { + background-color: transparent; + } + + &::-webkit-scrollbar-thumb { + border-radius: 8px; + background-color: transparent; + } + + &::-webkit-scrollbar-thumb:hover { + background-color: $devui-placeholder; + } + + &::-webkit-scrollbar-corner { + background-color: transparent; + } + } +} + +.#{$devui-prefix}-data-grid { + position: relative; + width: 100%; + height: 100%; + max-height: inherit; + + &__x-space, + &__y-space { + position: absolute; + inset: 0; + z-index: -1; + } + + &__empty { + position: absolute; + top: 47px; + z-index: 5; + width: 100%; + } + + &__head-wrapper { + position: relative; + flex: none; + overflow-x: hidden; + + &::-webkit-scrollbar-thumb { + background-color: transparent; + } + } + + &__head { + display: flex; + background-color: $devui-base-bg; + width: fit-content; + } + + &__th { + position: relative; + display: flex; + align-items: center; + flex-shrink: 0; + flex-grow: 0; + font-size: $devui-font-size-sm; + font-weight: 700; + padding: 0 16px; + border-bottom: 1px solid $devui-dividing-line; + color: $devui-text; + + .th-title { + @include overflow-ellipsis(); + } + + svg.th-sort-icon { + margin-left: 8px; + visibility: hidden; + cursor: pointer; + + g { + use { + fill: $devui-shape-icon-fill; + } + + polygon { + fill: $devui-icon-bg; + } + } + + &:hover { + g use { + fill: $devui-shape-icon-fill-active; + } + } + + &.asc { + visibility: visible; + + g { + use { + fill: $devui-brand; + } + + polygon:last-of-type { + opacity: 0.3; + } + } + } + + &.desc { + visibility: visible; + + g { + use { + fill: $devui-brand; + } + + polygon:last-of-type { + opacity: 0.3; + } + } + } + + &.th-sort-default-visible { + visibility: visible; + } + } + + svg.th-filter-icon { + display: block; + height: 16px; + margin-left: 8px; + text-align: right; + visibility: hidden; + cursor: pointer; + + g { + fill: $devui-shape-icon-fill; + } + + &:hover { + g { + fill: $devui-shape-icon-fill-active; + } + } + + &.th-filter-default-visible { + visibility: visible; + } + } + + &:hover { + border-radius: $devui-border-radius 0 0 $devui-border-radius; + + .resize-handle { + border-right: 2px solid $devui-line; + + &::before { + @include resize-handle-arrow(); + + left: -8px; + border-right-color: $devui-line; + } + + &::after { + @include resize-handle-arrow(); + + left: 6px; + border-left-color: $devui-line; + } + } + + .th-sort-icon, + .th-filter-icon { + visibility: visible; + } + } + + &:last-child:hover { + .resize-handle { + &::after { + display: none; + } + } + } + + .resize-handle { + display: inline-block; + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 5px; + cursor: col-resize; + + &:hover { + border-right: 2px solid $devui-form-control-line-active; + + &::before, + &::after { + display: none; + } + } + } + } + + &__th--mini { + height: 24px; + line-height: 24px; + } + + &__th--xs { + height: 32px; + line-height: 32px; + } + + &__th--sm, + &__th--md, + &__th--lg { + height: 42px; + line-height: 42px; + } + + &__th--operable:hover { + background-color: $devui-list-item-hover-bg; + } + + &__th--sort-active { + background-color: $devui-list-item-hover-bg; + border-radius: $devui-border-radius 0 0 $devui-border-radius; + } + + &__th--filter-active { + background-color: $devui-list-item-hover-bg; + border-radius: $devui-border-radius 0 0 $devui-border-radius; + + svg.th-filter-icon { + visibility: visible; + + g { + fill: $devui-brand; + } + + &:hover { + g { + fill: $devui-brand; + } + } + } + } + + &__body-wrapper { + position: relative; + width: 100%; + flex: 1; + + .#{$devui-prefix}-data-grid__empty { + top: 0; + } + } + + &__body { + width: fit-content; + } + + &__tr { + display: flex; + width: fit-content; + background-color: $devui-base-bg; + } + + &__td { + flex-shrink: 0; + flex-grow: 0; + font-size: $devui-font-size; + padding: 0 16px; + border-bottom: 1px solid $devui-dividing-line; + @include overflow-ellipsis(); + + .tree-indent-placeholder { + display: inline-block; + } + + svg.toggle-tree-icon { + padding-right: 8px; + margin-top: -2px; + vertical-align: middle; + box-sizing: content-box; + cursor: pointer; + } + + svg.expand-icon { + rect { + stroke: $devui-disabled-text; + + &:last-child { + stroke: none; + fill: $devui-disabled-text; + } + } + + &:hover { + rect { + stroke: $devui-icon-fill-active; + + &:last-child { + stroke: none; + fill: $devui-icon-fill-active; + } + } + } + } + + svg.fold-icon { + rect { + stroke: $devui-disabled-text; + } + + path { + fill: $devui-disabled-text; + } + + &:hover { + rect { + stroke: $devui-icon-fill-active; + } + + path { + fill: $devui-icon-fill-active; + } + } + } + } + + &__td--checkable { + display: flex; + align-items: center; + } + + &__td--mini { + height: 24px; + line-height: 24px; + } + + &__td--xs { + height: 30px; + line-height: 30px; + } + + &__td--sm { + height: 42px; + line-height: 42px; + } + + &__td--md { + height: 46px; + line-height: 46px; + } + + &__td--lg { + height: 54px; + line-height: 54px; + } + + &__last-sticky-left-cell { + border-right-color: transparent !important; + } + + &__first-sticky-right-cell { + border-left-color: transparent !important; + } + + &__sticky-left-head, + &__sticky-right-head, + &__sticky-left-body, + &__sticky-right-body { + position: absolute; + z-index: 10; + } + + &--scroll-middle, + &--scroll-right { + .#{$devui-prefix}-data-grid__last-sticky-left-cell { + position: relative; + border-right-color: transparent !important; + background-color: linear-gradient(to left, transparent, $devui-base-bg 10px); + + &::after { + content: ''; + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 10px; + pointer-events: none; + box-shadow: inset $devui-table-inset-shadow-left $devui-light-shadow; + } + } + } + + &--scroll-middle, + &--scroll-left { + .#{$devui-prefix}-data-grid__first-sticky-right-cell { + position: relative; + border-left-color: transparent !important; + background-color: linear-gradient(to right, transparent, $devui-base-bg 10px); + + &::after { + content: ''; + position: absolute; + top: 0; + left: 0; + bottom: 0; + width: 10px; + pointer-events: none; + box-shadow: inset $devui-table-inset-shadow-right $devui-light-shadow; + } + } + } + + &__tooltip.#{$devui-prefix}-flexible-overlay { + max-width: 200px; + min-height: 26px; + padding: 0 16px; + font-size: $devui-font-size; + color: $devui-feedback-overlay-text; + letter-spacing: 0; + line-height: 1.5; + background: $devui-feedback-overlay-bg; + box-shadow: none; + overflow-wrap: break-word; + word-break: break-word; + word-wrap: break-word; + text-align: start; + border-radius: $devui-border-radius-feedback; + line-break: auto; + text-decoration: none; + text-shadow: none; + text-transform: none; + word-spacing: normal; + white-space: normal; + opacity: 1; + z-index: $devui-z-index-pop-up; + + span { + display: block; + max-width: 100%; + max-height: inherit; + padding: 4px 0; + overflow: auto; + } + } + + &__filter-wrapper { + font-size: $devui-font-size; + + & * { + box-sizing: border-box; + } + + .filter-all-check { + width: 200px; + padding: 0 8px 4px; + border-bottom: 1px solid $devui-dividing-line; + } + + .filter-multiple-menu { + width: 200px; + padding: 4px 8px; + border-bottom: 1px solid $devui-dividing-line; + } + + .filter-single-menu { + width: 200px; + + .filter-item { + padding: 0 8px; + color: $devui-text; + border-radius: $devui-border-radius; + transition: color $devui-animation-duration-fast $devui-animation-ease-in-out-smooth, + background-color $devui-animation-duration-fast $devui-animation-ease-in-out-smooth; + + &:hover { + color: $devui-list-item-hover-text; + background-color: $devui-list-item-hover-bg; + } + } + + .filter-item-active { + color: $devui-list-item-active-bg; + background-color: $devui-list-item-active-text; + } + } + + .filter-operation { + display: flex; + justify-content: center; + align-items: center; + padding: 0 8px; + height: 26px; + } + + .filter-item { + display: flex; + align-items: center; + height: 30px; + cursor: pointer; + @include overflow-ellipsis(); + } + } + + &--fix-header { + display: flex; + flex-flow: column nowrap; + overflow: unset; + } + + &--striped { + .#{$devui-prefix}-data-grid__tr:nth-of-type(even) { + background-color: $devui-list-item-strip-bg; + } + } + + &--row-hover-highlight { + .#{$devui-prefix}-data-grid__tr.hover-tr { + background-color: $devui-list-item-hover-bg; + + .#{$devui-prefix}-data-grid__last-sticky-left-cell { + background-color: linear-gradient(to left, transparent, $devui-list-item-hover-bg 10px); + } + + .#{$devui-prefix}-data-grid__first-sticky-right-cell { + background-color: linear-gradient(to right, transparent, $devui-list-item-hover-bg 10px); + } + } + } + + &--header-bg { + .#{$devui-prefix}-data-grid__head { + background-color: $devui-list-item-strip-bg; + } + } + + &--bordered { + .#{$devui-prefix}-data-grid__th { + border-top: 1px solid $devui-dividing-line; + border-right: 1px solid $devui-dividing-line; + + &:first-child { + border-left: 1px solid $devui-dividing-line; + } + } + + .#{$devui-prefix}-data-grid__td { + border-right: 1px solid $devui-dividing-line; + + &:first-child { + border-left: 1px solid $devui-dividing-line; + } + } + } + + &--borderless { + .#{$devui-prefix}-data-grid__th { + border: none; + } + + .#{$devui-prefix}-data-grid__td { + border: none; + } + } + + &--shadowed { + border-radius: $devui-border-radius-card; + box-shadow: $devui-shadow-length-base $devui-light-shadow; + } + + &--left { + text-align: left; + + &.#{$devui-prefix}-data-grid__th, + &.#{$devui-prefix}-data-grid__td { + justify-content: flex-start; + } + } + + &--center { + text-align: center; + + &.#{$devui-prefix}-data-grid__th, + &.#{$devui-prefix}-data-grid__td { + justify-content: center; + } + } + + &--right { + text-align: right; + + &.#{$devui-prefix}-data-grid__th, + &.#{$devui-prefix}-data-grid__td { + justify-content: flex-end; + } + } + + &--is-virtual { + max-height: unset; + } + + .resize-bar { + display: none; + position: absolute; + top: 0; + bottom: 0; + z-index: 9999; + width: 2px; + background: $devui-form-control-line-active; + cursor: col-resize; + } +} + +.data-grid-selector { + user-select: none; + cursor: col-resize; +} \ No newline at end of file diff --git a/packages/devui-vue/devui/data-grid/src/data-grid.tsx b/packages/devui-vue/devui/data-grid/src/data-grid.tsx new file mode 100644 index 0000000000..38cd7a5e91 --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/data-grid.tsx @@ -0,0 +1,106 @@ +import { defineComponent, provide, toRefs, ref } from "vue"; +import type { SetupContext } from 'vue'; +import FixHeadGrid from './components/fix-head-grid'; +import NormalHeadGrid from './components/normal-head-grid'; +import { dataGridProps, DataGridInjectionKey } from "./data-grid-types"; +import type { DataGridProps } from "./data-grid-types"; +import { useDataGrid, useDataGridStyle } from "./composables/use-data-grid"; +import './data-grid.scss'; + +export default defineComponent({ + name: 'DDataGrid', + props: dataGridProps, + emits: [ + 'loadMore', + 'sortChange', + 'checkChange', + 'checkAllChange', + 'expandChange', + 'expandAllChange', + 'rowClick', + 'cellClick', + 'resizeStart', + 'resizing', + 'resizeEnd', + 'expandLoadMore', + ], + setup(props: DataGridProps, ctx: SetupContext) { + const { fixHeader, showHeader, lazy, rowClass, cellClass, size, indent, virtualScroll, resizable } = toRefs(props); + const rootRef = ref(); + const { + scrollRef, + headBoxRef, + bodyScrollLeft, + bodyContentHeight, + bodyContentWidth, + translateX, + translateY, + renderFixedLeftColumnData, + renderFixedRightColumnData, + renderColumnData, + renderRowData, + isTreeGrid, + allChecked, + halfAllChecked, + sort, + getCheckedRows, + execSortMethod, + addGridThContextToMap, + clearAllSortState, + toggleRowExpansion, + toggleAllRowExpansion, + toggleRowChecked, + toggleAllRowChecked, + afterColumnDragend, + refreshRowsData, + } = useDataGrid(props, ctx); + const { gridClasses } = useDataGridStyle(props, scrollRef); + + provide(DataGridInjectionKey, { + rowClass, + cellClass, + size, + fixHeader, + showHeader, + lazy, + indent, + virtualScroll, + resizable, + bodyContentWidth, + bodyContentHeight, + translateX, + translateY, + bodyScrollLeft, + renderColumnData, + renderFixedLeftColumnData, + renderFixedRightColumnData, + renderRowData, + rootRef, + scrollRef, + headBoxRef, + rootCtx: ctx, + isTreeGrid, + allChecked, + halfAllChecked, + execSortMethod, + addGridThContextToMap, + clearAllSortState, + toggleRowExpansion, + toggleRowChecked, + toggleAllRowChecked, + afterColumnDragend + }); + + ctx.expose({ sort, toggleRowChecked, toggleAllRowChecked, getCheckedRows, toggleRowExpansion, toggleAllRowExpansion, refreshRowsData }); + + return () => ( +
    + {fixHeader.value ? ( + + ) : ( + + )} +
    + ); + } +}); diff --git a/packages/devui-vue/devui/data-grid/src/utils.ts b/packages/devui-vue/devui/data-grid/src/utils.ts new file mode 100644 index 0000000000..e7947f7f77 --- /dev/null +++ b/packages/devui-vue/devui/data-grid/src/utils.ts @@ -0,0 +1,148 @@ +import { v4 as uuidv4 } from 'uuid'; +import type { Ref } from 'vue'; +import { isFunction } from 'lodash'; +import type { RowData, InnerRowData, RowKey, InnerColumnConfig, ColumnConfig } from './data-grid-types'; +import { ColumnMinWidth } from './const'; + +export function getRealWidth(width: string | number | undefined, totalWidth: number) { + if (width === undefined) { + return width; + } + if (typeof width === 'string') { + if (width.endsWith('%')) { + return totalWidth * (parseInt(width) / 100); + } else { + return parseInt(width); + } + } else { + return width; + } +} + +export function calcEachColumnWidth(columns: ColumnConfig[], containerWidth: number): ColumnConfig[] { + const flexColumnIndex: number[] = []; + const result: ColumnConfig[] = []; + let totalMinWidth = 0; + + // 根据配置的width参数计算每列宽度 + for (let i = 0; i < columns.length; i++) { + const item = { ...columns[i] }; + item.minWidth = getRealWidth(item.minWidth, containerWidth) ?? ColumnMinWidth; + item.maxWidth = getRealWidth(item.maxWidth, containerWidth) ?? Infinity; + if (item.width) { + item.width = getRealWidth(item.width, containerWidth) as number; + totalMinWidth += item.width; + } else { + // 记录没有配置width参数的列索引 + flexColumnIndex.push(i); + } + result.push(item); + } + + if (flexColumnIndex.length) { + const remainWidth = containerWidth - totalMinWidth; + + if (remainWidth > 0) { + const flexColumnItemWidth = remainWidth / flexColumnIndex.length; + flexColumnIndex.forEach((item) => { + result[item].width = Math.min(result[item].maxWidth as number, Math.max(result[item].minWidth as number, flexColumnItemWidth)); + }); + } else { + flexColumnIndex.forEach((item) => { + result[item].width = result[item].minWidth; + }) + } + } + + return result; +} + +export function getXStartOrEndIndex(list: InnerColumnConfig[], distance: number) { + let start = 0; + let end = list.length - 1; + while (start < end) { + const mid = Math.floor((start + end) / 2); + const { width, offsetLeft } = list[mid]; + if (distance >= offsetLeft && distance < (width as number) + offsetLeft) { + start = mid; + break; + } else if (distance >= (width as number) + offsetLeft) { + start = mid + 1; + } else if (distance < offsetLeft) { + end = mid - 1; + } + } + return start; +} + +export function getYStartIndex(list: RowData[], distance: number) { + let start = 0; + let end = list.length - 1; + + while (start < end) { + const mid = Math.floor((start + end) / 2); + const { height, offsetTop } = list[mid]; + if (distance >= offsetTop && distance < height + offsetTop) { + start = mid; + break; + } else if (distance >= height + offsetTop) { + start = mid + 1; + } else if (distance < offsetTop) { + end = mid - 1; + } + } + + return start; +} + +export function generateInnerData( + rowDataList: RowData[], + rowIndex: Ref, + rowKey: Ref | undefined, + level = 0, + parentNode: InnerRowData = {} +) { + level++; + const result: InnerRowData[] = []; + + for (let i = 0; i < rowDataList.length; i++) { + const newItem: InnerRowData = rowDataList[i]; + newItem.$rowIndex = rowIndex.value; + newItem.$level = level; + newItem.showNode = level === 1; + newItem.$expand = false; + newItem.isLeaf = newItem.isLeaf ?? !newItem.children?.length; + rowIndex.value++; + + if (rowKey && rowKey.value) { + if (typeof rowKey.value === 'string') { + newItem.$rowId = newItem[rowKey.value]; + } else if (isFunction(rowKey.value)) { + newItem.$rowId = rowKey.value(rowDataList[i]); + } else { + newItem.$rowId = uuidv4(); + } + } else { + newItem.$rowId = uuidv4(); + } + + if (parentNode.$rowId) { + newItem.$parentId = parentNode.$rowId; + } + + if (!(newItem.children && newItem.children.length)) { + newItem.childList = []; + result.push(newItem); + } else { + const childrenNodes = generateInnerData(newItem.children, rowIndex, rowKey, level, newItem); + Reflect.deleteProperty(newItem, 'children'); + newItem.childList = childrenNodes.filter((child) => child.$parentId === newItem.$rowId); + newItem.descendantList = childrenNodes; + const childCheckedNodes = childrenNodes.filter((child) => child.checked); + newItem.halfChecked = childCheckedNodes.length !== 0 && childrenNodes.length !== childCheckedNodes.length; + result.push(newItem, ...childrenNodes); + } + } + + return result; +} \ No newline at end of file diff --git a/packages/devui-vue/devui/locale/lang/en-us.ts b/packages/devui-vue/devui/locale/lang/en-us.ts index 5a19c92ae9..f099c90a59 100644 --- a/packages/devui-vue/devui/locale/lang/en-us.ts +++ b/packages/devui-vue/devui/locale/lang/en-us.ts @@ -65,6 +65,10 @@ export default { selectAll: 'Select all', ok: 'OK', }, + dataGrid: { + selectAll: 'Select all', + ok: 'OK', + }, timePopup: { ok: 'OK', }, diff --git a/packages/devui-vue/devui/locale/lang/zh-cn.ts b/packages/devui-vue/devui/locale/lang/zh-cn.ts index 7a5489c96b..b9cb78ee95 100644 --- a/packages/devui-vue/devui/locale/lang/zh-cn.ts +++ b/packages/devui-vue/devui/locale/lang/zh-cn.ts @@ -66,6 +66,10 @@ export default { selectAll: '全选', ok: '确定', }, + dataGrid: { + selectAll: '全选', + ok: '确定', + }, timePopup: { ok: '确定', }, diff --git a/packages/devui-vue/devui/style/global.scss b/packages/devui-vue/devui/style/global.scss new file mode 100644 index 0000000000..79db82c818 --- /dev/null +++ b/packages/devui-vue/devui/style/global.scss @@ -0,0 +1,37 @@ +@import '@devui/theme/styles-var/devui-var.scss'; + +.devui-scroll-overlay { + overflow: auto; + + &::-webkit-scrollbar-thumb { + background-color: transparent; + } + + &:hover { + &::-webkit-scrollbar-thumb { + background-color: $devui-line; + } + + &::-webkit-scrollbar-thumb:hover { + background-color: $devui-placeholder; + } + } +} + +@-moz-document url-prefix() { + body * { + scrollbar-width: thin; + + .#{$devui-prefix}-data-grid__head-wrapper { + scrollbar-color: transparent transparent; + } + + .devui-scroll-overlay { + scrollbar-color: transparent transparent; + + &:hover { + scrollbar-color: $devui-line transparent; + } + } + } +} \ No newline at end of file diff --git a/packages/devui-vue/devui/style/index.scss b/packages/devui-vue/devui/style/index.scss new file mode 100644 index 0000000000..4ee764d585 --- /dev/null +++ b/packages/devui-vue/devui/style/index.scss @@ -0,0 +1 @@ +@import './global.scss'; \ No newline at end of file diff --git a/packages/devui-vue/docs/components/data-grid/index.md b/packages/devui-vue/docs/components/data-grid/index.md new file mode 100644 index 0000000000..54d8ffa0fb --- /dev/null +++ b/packages/devui-vue/docs/components/data-grid/index.md @@ -0,0 +1,1999 @@ +# DataGrid 表格 + +展示行列数据。 + +### 基本用法 + +:::demo `data`参数传入要展示的数据,`columns`参数传入列数据;列数据中的`field`参数为对应列内容的字段名,`header`参数为对应列的标题。 + +```vue + + + +``` + +::: + +### 表格样式 + +:::demo `striped`参数设置是否显示斑马纹;`header-bg`参数设置是否显示表头背景色;`border-type`参数设置边框类型;`shadow-type`参数设置阴影类型;`show-header`参数设置是否显示表头;列配置中的`align`参数设置对齐方式。 + +```vue + + + + + +``` + +::: + +### 动态数据 + +:::demo loading 由业务自行添加。 + +```vue + + + + + +``` + +::: + +### 动态列 + +:::demo + +```vue + + + +``` + +::: + +### 懒加载 + +:::demo `lazy`参数设置为`true`,即可启用懒加载,在`load-more`事件回调中可动态添加数据。 + +```vue + + + +``` + +::: + +### 自定义行样式 + +:::demo 通过`row-class`参数自定义行样式,可传入字符串,来自定义每一行的样式;也可传入函数,来自定义某一行或某几行的样式,函数参数为行数据和行索引。 + +```vue + + + + + +``` + +::: + +### 自定义单元格样式 + +:::demo 通过`cell-class`参数自定义行样式,可传入字符串,来自定义每一行的样式;也可传入函数,来自定义某一行或某几行的样式,函数参数为行数据、行索引、列数据、列索引。 + +```vue + + + + + +``` + +::: + +### 自定义列宽 + +:::demo 通过`width`参数配置列宽,参数类型为`string | number | undefined`,`number`类型为固定宽度;`string`类型可配置为像素或者百分比,当为百分比时,基于表格所在容器总宽计算该列实际宽度;当为`undefined`即不配置列宽参数时,会与其他不配置列宽的列平分剩余宽度(即容器总宽减去已知列宽)。
    通过`minWidth`参数配置最小列宽,参数类型同`width`,当该列未配置`width`参数时,若给该列分配的宽度小于`minWidth`,则按照`minWidth`设置列宽,未配置`minWidth`参数时,为保证该列能够显示,会默认设置`80px`宽度。
    通过`maxWidth`参数配置最大列宽,参数类型同`width`,当该列未配置`width`参数时,若给该列分配的宽度大于`maxWidth`,则按照`maxWidth`设置列宽。 + +```vue + + + +``` + +::: + +### 自定义单元格内容 + +:::demo 通过在列数据的`cellRender`参数来自定义单元格内容,函数参数依次为行数据、行索引、列数据、列索引。`cellRender`函数可返回由[h 函数](https://cn.vuejs.org/api/render-function.html#h)创建的虚拟 DOM 节点。 + +```vue + + + +``` + +::: + +### 自定义表头 + +:::demo 通过在列数据的`headRender`参数来自定义表头,函数参数为当前列数据。`headRender`函数可返回由[h 函数](https://cn.vuejs.org/api/render-function.html#h)创建的虚拟 DOM 节点。 + +```vue + + + +``` + +::: + +### 自定义提示内容 + +:::demo `showOverflowTooltip`参数可设置内容超出后,鼠标悬浮是否显示提示内容,可设置为`true`值来开启此功能,也可通过`TooltipConfig`类型的参数来对提示内容做一些配置。
    `showHeadOverflowTooltip`用来设置表头,作用及参数值与`showOverflowTooltip`一致。 + +```vue + + + +``` + +::: + +### 空数据模板 + +:::demo 通过`empty`插槽可自定义数据为空时显示的内容。 + +```vue + + + + + +``` + +::: + +### 固定表头 + +:::demo `fix-header`参数设置为`true`即可固定表头。 + +```vue + + + +``` + +::: + +### 固定列 + +:::demo 通过列数据的`fixed`参数可将该列固定,参数值为`left`和`right`,可分别固定在左侧和右侧。 + +```vue + + + +``` + +::: + +### 排序 + +:::demo 列数据中的`sortable`参数设置为`true`可启用排序功能,`sortMethod`参数自定义排序方法,排序后触发`sort-change`事件,事件抛出产生排序的列字段以及当前排序方式。 + +```vue + + + +``` + +::: + +### 过滤 + +:::demo 列数据中的`filterable`参数设置为`true`可启用过滤功能,内置过滤器默认为多选,通过`filterMultiple: false`可设置单选过滤器;`filterList`参数设置过滤器列表;`filterChange`参数为过滤条件变更后的回调;`filterMenu`参数可以自定义过滤器,函数可返回由[h 函数](https://cn.vuejs.org/api/render-function.html#h)创建的虚拟 DOM 节点。。 + +```vue + + + +``` + +::: + +### 树表格 + +行数据中包含`children`字段,则默认展示为树表格;若想在展开某一行时异步加载数据,可将展开行的`isLeaf`设置为`false`,当展开该行时会触发`expand-load-more`事件,事件抛出当前展开行的数据和回调函数,加载完成后执行回调函数将数据回填进表格中;`toggleRowExpansion`可切换某一行的展开状态,第一个参数为行数据,第二个参数可选,可设置展开状态。 + +:::demo + +```vue + + + +``` + +::: + +### 列宽拖拽 + +:::demo 列数据配置`resizable`为`true`,使该列可拖拽,拖拽后,会通过最后一列做宽度补偿。**虚拟滚动暂时不支持列宽拖拽**。 + +```vue + + + +``` + +::: + +### 可选择 + +:::demo 列数据中`type`参数设置为`checkable`可启用勾选功能;行数据中`checked`参数可设置默认勾选状态,`disableCheck`参数可设置禁用勾选;行勾选状态变更时触发`check-change`事件,事件参数为当前行的勾选状态和行数据;表头勾选状态变更时触发`check-all-change`事件,事件参数为当前勾选状态。 + +```vue + + + +``` + +::: + +### 父子联动 + +:::demo 在搭配树形表格使用时,通过`checkable-relation`参数可以控制父子联动方式,默认为`both`,即勾选状态改变会同时影响父和子;其他可选参数为`downward`、`upward`、`none`,具体表现参考 demo 。 + +```vue + + + +``` + +::: + +### 操作方法 + +:::demo `toggleRowChecked`方法切换行的勾选状态,第一个参数为行数据,第二个参数可选,可设置勾选状态;`toggleAllRowChecked`方法切换全选状态,参数可选,可设置勾选状态;`getCheckedRows`方法获取当前已勾选数据。 + +```vue + + + +``` + +::: + +### 大数据 + +:::demo + +```vue + + + +``` + +::: + +### DataGrid 参数 + +| 参数名 | 类型 | 默认值 | 说明 | +| :-------------------- | :-------------------------------------- | :----- | :-------------------------------------------------------------------- | +| data | [RowData[]](#rowdata) | [] | 表格数据 | +| columns | [ColumnConfig[]](#columnconfig) | [] | 列配置数据 | +| indent | `number` | 16 | 树形表格缩进量,单位`px` | +| striped | `boolean` | false | 是否显示斑马纹 | +| fix-header | `boolean` | false | 是否固定表头 | +| row-hovered-highlight | `boolean` | true | 鼠标悬浮是否高亮行 | +| header-bg | `boolean` | false | 是否显示表头背景色 | +| show-header | `boolean` | true | 是否显示表头 | +| lazy | `boolean` | false | 是否懒加载 | +| virtual-scroll | `boolean` | false | 是否启用虚拟滚动 | +| reserve-check | `boolean` | false | 是否保留勾选状态 | +| resizable | `boolean` | -- | 可选,是否所有列支持拖拽调整列宽,列的 resizable 参数优先级高于此参数 | +| row-class | [RowClass](#rowclass) | '' | 自定义行样式,可设置为函数,不同行设置不同样式 | +| row-key | [RowKey](#rowkey) | -- | 勾选行、树表格等场景为必填,需要根据此字段定义的唯一 key 查找数据 | +| cell-class | [CellClass](#cellclass) | '' | 自定义单元格样式,可设置为函数,不同单元格设置不同样式 | +| border-type | [BorderType](#bordertype) | '' | 边框类型 | +| shadow-type | [ShadowType](#shadowtype) | '' | 阴影类型 | +| size | [Size](#size) | 'sm' | 表格大小,反应在行高的不同上 | +| checkable-relation | [CheckableRelation](#checkablerelation) | 'both' | 行勾选和树形表格组合使用时,用来定义父子联动关系 | + +### DataGrid 事件 + +| 事件名 | 回调参数 | 说明 | +| :--------------- | :--------------------------------------------------------------------------- | :------------------------------------------------------------- | +| row-click | `Function(e: RowClickArg)` | 行点击时触发的事件 | +| cell-click | `Function(e: CellClickArg)` | 单元格点击时触发的事件 | +| check-change | `Function(status: boolean, rowData: RowData)` | 行勾选状态变化时触发的事件,参数为当前勾选状态和行数据 | +| check-all-change | `Function(status: boolean)` | 表头勾选状态变化时触发的事件,参数为当前勾选状态 | +| expand-change | `Function(status: boolean, rowData: RowData)` | 树表格,行展开状态变化时触发的事件,参数为当前展开状态和行数据 | +| sort-change | `Function(e: SortChangeArg)` | 排序变化时触发的事件 | +| load-more | `Function()` | 懒加载触发的事件 | +| expand-load-more | `Function(node: RowData, callback: (result: IExpandLoadMoreResult) => void)` | 树表格展开时触发的懒加载事件 | + +### DataGrid 方法 + +| 方法名 | 类型 | 说明 | +| :-------------------- | :--------------------------------------------------------------- | :--------------------------------------------------- | +| sort | `(key: ColumnConfig['field'], direction: SortDirection) => void` | 对某列按指定方式进行排序 | +| toggleRowChecked | `(node: RowData, status?: boolean) => void` | 切换指定行勾选状态,可通过`status`参数指定勾选状态 | +| toggleAllRowChecked | `(status?: boolean) => void` | 切换表头勾选状态,可通过`status`参数指定勾选状态 | +| getCheckedRows | `() => RowData[]` | 获取已勾选的行数据 | +| toggleRowExpansion | `(node: RowData, status?: boolean) => void` | 切换指定行的展开状态,可通过`status`参数指定展开状态 | +| toggleAllRowExpansion | `(status?: boolean) => void` | 切换所有行的展开状态,可通过`status`参数指定展开状态 | +| refreshRowsData | `() => void` | 根据当前传入的`data`,重新计算需要显示的行数据 | + +### 类型定义 + +#### RowData + +```ts +interface RowData { + checked?: boolean; // 是否勾选 + disableCheck?: boolean; // 是否禁用勾选 + children?: RowData[]; // 当存在此字段时,默认展示树表格 + isLeaf?: boolean; // 是否为叶子节点,当树表格中需要异步加载子节点时,需要将此参数设置为false + [k: string]: any; // 业务其他数据 +} +``` + +#### ColumnConfig + +```ts +interface ColumnConfig { + header: string; // 列的头部展示内容 + field: string; // 列字段,用于从 RowData 取数据展示在单元格 + width?: number | string; // 列宽度;可设置百分比,会根据容器总宽度计算单元格实际所占宽度,未设置时,会自动分配宽度,分配规则参考【自定义样式-自定义列宽】示例;启用虚拟滚动此字段必填 + minWidth?: number | string; // 最小列宽,可设置百分比,会根据容器总宽度计算实际最小列宽 + maxWidth?: number | string; // 最大列宽,可设置百分比,会根据容器总宽度计算实际最大列宽 + type?: ColumnType; // 列类型,复选框、索引等 + resizable?: boolean; // 是否支持拖拽调整列宽 + sortable?: boolean; // 是否启用排序 + showSortIcon?: boolean; // 是否显示排序未激活图标,默认不显示 + sortMethod?: SortMethod; // 自定义排序方法 + filterable?: boolean; // 是否启用过滤 + showFilterIcon?: boolean; // 是否显示筛选未激活图标,默认不显示 + filterMultiple?: boolean; // 组件内置多选和单选两种过滤器,默认为多选,配置为 false 可使用单选过滤器 + filterList?: FilterListItem[]; // 过滤器列表 + filterChange?: (val: FilterListItem | FilterListItem[]) => void; // 过滤内容变更时触发 + filterMenu?: (scope: { toggleFilterMenu: (status?: boolean) => void; setFilterStatus: (status: boolean) => void }) => VNode; // 自定义过滤器;toggleFilterMenu: 展开收起筛选菜单;setFilterStatus: 设置表头是否高亮 + fixed?: FixedDirection; // 固定列的方向,固定在左侧 or 右侧 + align?: ColumnAlign; // 列内容对齐方式 + showOverflowTooltip?: boolean | TooltipConfig; // 单元格内容超长是否通过 Tooltip 显示全量内容,可对 Tooltip 进行配置,支持的配置项参考 TooltipConfig + showHeadOverflowTooltip?: boolean | TooltipConfig; // 表头内容超长是否通过 Tooltip 显示全量内容,可对 Tooltip 进行配置,支持的配置项参考 TooltipConfig + headRender?: (columnConfig: ColumnConfig) => VNode; // 自定义表头 + cellRender?: (rowData: RowData, rowIndex: number, cellData: string, cellIndex: number) => VNode; // 自定义单元格 +} +``` + +#### ColumnType + +列类型 + +```ts +type ColumnType = 'checkable' | 'index' | ''; +``` + +#### FilterListItem + +过滤器列表项 + +```ts +interface FilterListItem { + name: string; // 显示的内容 + value: any; // 对应的值 +} +``` + +#### FixedDirection + +固定列的方向 + +```ts +type FixedDirection = 'left' | 'right'; +``` + +#### ColumnAlign + +列内容对齐方式 + +```ts +type ColumnAlign = 'left' | 'center' | 'right'; +``` + +#### TooltipConfig + +超长显示 Tooltip 的配置项 + +```ts +interface TooltipConfig { + content?: string; // 提示内容,默认为单元格内容 + position?: Placement[]; // 展开方向,默认展开顺序为上右下左 + mouseEnterDelay?: number; // 鼠标悬浮后延时多久提示,默认150ms + enterable?: boolean; // 鼠标是否可移入提示框,默认 true +} +``` + +#### RowClass + +自定义行样式的类名,配置为函数,可不同行设置不同样式 + +```ts +type RowClass = string | ((row: RowData, rowIndex: number) => string); +``` + +#### RowKey + +勾选行、树表格等场景需要通过方法操作时根据此字段定义的唯一 key 查找数据 + +```ts +type RowKey = string | ((row: RowData) => string); +``` + +#### CellClass + +自定义单元格样式,可设置为函数,不同单元格设置不同样式 + +```ts +type CellClass = string | ((row: RowData, rowIndex: number, column: ColumnConfig, columnIndex: number) => string); +``` + +#### BorderType + +```ts +type BorderType = '' | 'bordered' | 'borderless'; +``` + +#### ShadowType + +```ts +type ShadowType = '' | 'shadowed'; +``` + +#### Size + +```ts +type Size = 'mini' | 'xs' | 'sm' | 'md' | 'lg'; +``` + +#### CheckableRelation + +```ts +type CheckableRelation = 'upward' | 'downward' | 'both' | 'none'; +``` + +#### RowClickArg + +行点击事件的回调参数 + +```ts +interface RowClickArg { + row: RowData; // 行数据 + renderRowIndex: number; // 当前行在已渲染列表中的索引 + flattenRowIndex: number; // 当前行在所有数据中的索引,大数据时和 renderRowIndex 不一致 +} +``` + +#### CellClickArg + +单元格点击事件的回调参数 + +```ts +interface CellClickArg { + row: RowData; // 行数据 + renderRowIndex: number; // 当前行在已渲染列表中的索引 + flattenRowIndex: number; // 当前行在所有数据中的索引,大数据时和 renderRowIndex 不一致 + column: ColumnConfig; // 列配置数据 + columnIndex: number; // 列在所有数据中的索引 +} +``` + +#### SortDirection + +排序方式 + +```ts +type SortDirection = 'asc' | 'desc' | ''; +``` + +#### SortChangeArg + +排序事件回调参数 + +```ts +interface SortChangeArg { + field: ColumnConfig['field']; // 列字段 + direction: SortDirection; // 当前排序方式 +} +``` + +#### IExpandLoadMoreResult + +```ts +interface IExpandLoadMoreResult { + node: RowData; + rowItems: RowData[]; +} +``` diff --git a/packages/devui-vue/package.json b/packages/devui-vue/package.json index ee44664d29..f588bb4631 100644 --- a/packages/devui-vue/package.json +++ b/packages/devui-vue/package.json @@ -71,6 +71,7 @@ "mermaid": "9.1.1", "mitt": "^3.0.0", "monaco-editor": "0.34.0", + "uuid": "^9.0.1", "vue": "^3.2.37", "vue-router": "^4.0.3", "xss": "^1.0.14" From f0432c999f30509c8b5e31f3868932e6a50c2321 Mon Sep 17 00:00:00 2001 From: TongxinXie <30926943+CoderTongxin@users.noreply.github.com> Date: Tue, 26 Mar 2024 11:36:05 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=8B=96=E6=8B=BD2.0?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=20(#1803)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../devui-vue/devui/dragdrop-new/index.ts | 43 + .../src/batch-draggable.directive.ts | 204 ++ .../devui/dragdrop-new/src/directive-base.ts | 158 + .../dragdrop-new/src/drag-drop.service.ts | 316 ++ .../drag-preview-clone-dom-ref.component.tsx | 111 + .../src/drag-preview.component.tsx | 35 + .../src/drag-preview.directive.ts | 88 + .../dragdrop-new/src/draggable.directive.ts | 532 +++ .../src/drop-scroll-enhance.directive.ts | 469 +++ .../dragdrop-new/src/droppable.directive.ts | 839 +++++ .../src/preserve-next-event-emitter.ts | 137 + .../dragdrop-new/src/sortable.directive.ts | 41 + .../dragdrop-new/src/sync/desc-reg.service.ts | 71 + .../sync/drag-drop-descendant-sync.service.ts | 11 + .../src/sync/drag-drop-sync-box.directive.ts | 80 + .../src/sync/drag-drop-sync.service.ts | 30 + .../src/sync/drag-sync.directive.ts | 104 + .../src/sync/drop-sort-sync.directive.ts | 147 + .../devui/dragdrop-new/src/sync/index.ts | 23 + .../devui/dragdrop-new/src/sync/query-list.ts | 115 + .../src/touch-support/dragdrop-touch.ts | 576 ++++ .../devui-vue/devui/dragdrop-new/src/utils.ts | 168 + .../docs/components/dragdrop-new/index.md | 3036 +++++++++++++++++ packages/devui-vue/package.json | 9 +- pnpm-lock.yaml | 354 +- 25 files changed, 7543 insertions(+), 154 deletions(-) create mode 100644 packages/devui-vue/devui/dragdrop-new/index.ts create mode 100644 packages/devui-vue/devui/dragdrop-new/src/batch-draggable.directive.ts create mode 100644 packages/devui-vue/devui/dragdrop-new/src/directive-base.ts create mode 100644 packages/devui-vue/devui/dragdrop-new/src/drag-drop.service.ts create mode 100644 packages/devui-vue/devui/dragdrop-new/src/drag-preview-clone-dom-ref.component.tsx create mode 100644 packages/devui-vue/devui/dragdrop-new/src/drag-preview.component.tsx create mode 100644 packages/devui-vue/devui/dragdrop-new/src/drag-preview.directive.ts create mode 100644 packages/devui-vue/devui/dragdrop-new/src/draggable.directive.ts create mode 100644 packages/devui-vue/devui/dragdrop-new/src/drop-scroll-enhance.directive.ts create mode 100644 packages/devui-vue/devui/dragdrop-new/src/droppable.directive.ts create mode 100644 packages/devui-vue/devui/dragdrop-new/src/preserve-next-event-emitter.ts create mode 100644 packages/devui-vue/devui/dragdrop-new/src/sortable.directive.ts create mode 100644 packages/devui-vue/devui/dragdrop-new/src/sync/desc-reg.service.ts create mode 100644 packages/devui-vue/devui/dragdrop-new/src/sync/drag-drop-descendant-sync.service.ts create mode 100644 packages/devui-vue/devui/dragdrop-new/src/sync/drag-drop-sync-box.directive.ts create mode 100644 packages/devui-vue/devui/dragdrop-new/src/sync/drag-drop-sync.service.ts create mode 100644 packages/devui-vue/devui/dragdrop-new/src/sync/drag-sync.directive.ts create mode 100644 packages/devui-vue/devui/dragdrop-new/src/sync/drop-sort-sync.directive.ts create mode 100644 packages/devui-vue/devui/dragdrop-new/src/sync/index.ts create mode 100644 packages/devui-vue/devui/dragdrop-new/src/sync/query-list.ts create mode 100644 packages/devui-vue/devui/dragdrop-new/src/touch-support/dragdrop-touch.ts create mode 100644 packages/devui-vue/devui/dragdrop-new/src/utils.ts create mode 100644 packages/devui-vue/docs/components/dragdrop-new/index.md diff --git a/packages/devui-vue/devui/dragdrop-new/index.ts b/packages/devui-vue/devui/dragdrop-new/index.ts new file mode 100644 index 0000000000..fc9bbdddaa --- /dev/null +++ b/packages/devui-vue/devui/dragdrop-new/index.ts @@ -0,0 +1,43 @@ +import { App } from 'vue'; +import { DragDropService } from './src/drag-drop.service'; +import { default as Draggable } from './src/draggable.directive'; +import { default as Droppable } from './src/droppable.directive'; +import { default as Sortable } from './src/sortable.directive'; +import { default as DropScrollEnhanced, DropScrollEnhancedSide } from './src/drop-scroll-enhance.directive'; +import { default as BatchDraggable } from './src/batch-draggable.directive'; +import { default as DragPreview } from './src/drag-preview.directive'; +import { DragPreviewTemplate } from './src/drag-preview.component'; +import { default as DragPreviewCloneDomRef } from './src/drag-preview-clone-dom-ref.component'; +import { default as useDragDropSort } from './src/sync'; + +export * from './src/drag-drop.service'; +export * from './src/draggable.directive'; +export * from './src/droppable.directive'; +export * from './src/sortable.directive'; +export * from './src/drop-scroll-enhance.directive'; +export * from './src/batch-draggable.directive'; +export * from './src/drag-preview.component'; +export * from './src/drag-preview.directive'; +export * from './src/drag-preview-clone-dom-ref.component'; +export * from './src/sync'; + +export { Draggable, Droppable, Sortable, DropScrollEnhanced }; + +export default { + title: 'DragDrop 2.0 拖拽', + category: '通用', + status: '100%', + install(app: App): void { + app.directive('dDraggable', Draggable); + app.directive('dDroppable', Droppable); + app.directive('dSortable', Sortable); + app.directive('dDropScrollEnhanced', DropScrollEnhanced); + app.directive('dDropScrollEnhancedSide', DropScrollEnhancedSide); + app.directive('dDraggableBatchDrag', BatchDraggable); + app.directive('dDragPreview', DragPreview); + app.component('DDragPreviewTemplate', DragPreviewTemplate); + app.component(DragPreviewCloneDomRef.name, DragPreviewCloneDomRef); + app.provide(DragDropService.TOKEN, new DragDropService()); + app.use(useDragDropSort); + }, +}; diff --git a/packages/devui-vue/devui/dragdrop-new/src/batch-draggable.directive.ts b/packages/devui-vue/devui/dragdrop-new/src/batch-draggable.directive.ts new file mode 100644 index 0000000000..f668ac59af --- /dev/null +++ b/packages/devui-vue/devui/dragdrop-new/src/batch-draggable.directive.ts @@ -0,0 +1,204 @@ +import { EventEmitter } from './preserve-next-event-emitter'; +import { DragDropService } from './drag-drop.service'; +import { NgDirectiveBase, NgSimpleChanges } from './directive-base'; +import { DraggableDirective } from './draggable.directive'; +import { DirectiveBinding } from 'vue'; +import { injectFromContext } from './utils'; +export type BatchDragStyle = 'badge' | 'stack' | string; + +export interface IBatchDraggableBinding { + batchDragGroup?: string; + batchDragActive?: boolean; + batchDragLastOneAutoActiveEventKeys?: Array; + batchDragStyle?: string | Array; +} +export interface IBatchDraggableListener { + '@batchDragActiveEvent'?: (_: any) => void; +} + +export class BatchDraggableDirective extends NgDirectiveBase { + static INSTANCE_KEY = '__vueDevuiBatchDraggableDirectiveInstance'; + batchDragGroup = 'default'; + batchDragActive = false; + batchDragLastOneAutoActiveEventKeys = ['ctrlKey']; + batchDragStyle: Array = ['badge', 'stack']; + batchDragActiveEvent = new EventEmitter(); + dragData?: any; + needToRestore = false; + + constructor(private draggable: DraggableDirective, private dragDropService: DragDropService) { + super(); + this.draggable.batchDraggable = this; + } + + ngOnInit() { + this.initDragDataByIdentity(); + } + + ngOnDestroy() { + this.draggable.batchDraggable = undefined; + if (this.dragData) { + if (this.dragData.draggable === this.draggable) { + this.dragData.draggable = undefined; + if (!this.dragData.identity) { + this.removeFromBatchGroup(); + } + } + } + } + + ngOnChanges(changes: NgSimpleChanges): void { + if (changes['batchDragActive']) { + if (!this.initDragDataByIdentity()) { + if (this.batchDragActive) { + if (!this.dragData && this.allowAddToBatchGroup()) { + this.addToBatchGroup(); + } + } else { + this.removeFromBatchGroup(); + } + } + } + } + ngAfterViewInit() { + if (this.needToRestore) { + this.restoreDragDataViewAfterViewInit(); + this.needToRestore = false; + } + } + initDragDataByIdentity() { + const dragData = this.findInBatchDragDataByIdentities(); + if (dragData) { + if (this.batchDragActive) { + if (!this.dragData) { + this.addToBatchGroup(dragData); + this.registerRestoreDragDataViewAfterViewInitWhiteDragging(); + } + } else { + this.removeFromBatchGroup(dragData); + } + } + return dragData; + } + + registerRestoreDragDataViewAfterViewInitWhiteDragging() { + if ( + this.dragDropService.draggedEl && + this.dragDropService.draggedElIdentity && + this.dragDropService.draggedEl !== this.draggable.el.nativeElement + ) { + this.needToRestore = true; + } + } + restoreDragDataViewAfterViewInit() { + const draggable = this.draggable; + if (draggable.originPlaceholder && draggable.originPlaceholder.show !== false) { + draggable.insertOriginPlaceholder(true, false); + } + draggable.el.nativeElement.style.display = 'none'; + } + + allowAddToBatchGroup() { + if (!this.dragDropService.batchDragGroup) { + return true; + } else { + return this.batchDragGroup === this.dragDropService.batchDragGroup; + } + } + addToBatchGroup(dragData?: any) { + this.dragDropService.batchDragGroup = this.dragDropService.batchDragGroup || this.batchDragGroup; + if (dragData) { + dragData.draggable = this.draggable; + dragData.dragData = this.draggable.dragData; + this.dragData = dragData; + } else { + this.dragData = this.dragData || { + identity: this.draggable.dragIdentity || undefined, + draggable: this.draggable, + dragData: this.draggable.dragData, + }; + this.dragDropService.batchDragData = this.addToArrayIfNotExist(this.dragDropService.batchDragData!, this.dragData); + } + } + removeFromBatchGroup(dragData?: any) { + this.deleteFromArrayIfExist(this.dragDropService.batchDragData!, dragData || this.dragData); + this.dragData = undefined; + if (!(this.dragDropService.batchDragData && this.dragDropService.batchDragData.length)) { + this.dragDropService.batchDragGroup = undefined; + } + } + + private addToArrayIfNotExist(array: any[], target: any) { + array = array || []; + if (array.indexOf(target) === -1) { + array.push(target); + } + return array; + } + + private deleteFromArrayIfExist(array: any[], target: any) { + if (!array) { + return; + } + if (array.length > 0) { + const index = array.indexOf(target); + if (index > -1) { + array.splice(index, 1); + } + } + return array; + } + + private findInBatchDragDataByIdentities() { + if (!this.draggable.dragIdentity) { + return null; + } else if (!this.dragDropService.batchDragData) { + return undefined; + } else { + return this.dragDropService.batchDragData.filter((dragData) => dragData.identity === this.draggable.dragIdentity).pop(); + } + } + + active() { + this.batchDragActiveEvent.emit({ el: this.draggable.el.nativeElement, data: this.draggable.dragData }); + } + + public updateDragData() { + // 选中状态才更新 + if (!this.dragData) { + return; + } + // 需要维持内存地址不变 + Object.assign(this.dragData, { + identity: this.draggable.dragIdentity || undefined, + draggable: this.draggable, + dragData: this.draggable.dragData, + }); + } +} + +export default { + mounted(el: HTMLElement & { [props: string]: any }, binding: DirectiveBinding, vNode) { + const context = vNode['ctx'].provides; + const dragDropService = injectFromContext(DragDropService.TOKEN, context) as DragDropService; + const draggableDirective = injectFromContext(DraggableDirective.TOKEN, context) as DraggableDirective; + const batchDraggableDirective = (el[BatchDraggableDirective.INSTANCE_KEY] = new BatchDraggableDirective( + draggableDirective, + dragDropService + )); + batchDraggableDirective.setInput(binding.value); + batchDraggableDirective.mounted(); + batchDraggableDirective.ngOnInit?.(); + setTimeout(() => { + batchDraggableDirective.ngAfterViewInit?.(); + }, 0); + }, + updated(el: HTMLElement & { [props: string]: any }, binding: DirectiveBinding) { + const batchDraggableDirective = el[BatchDraggableDirective.INSTANCE_KEY] as BatchDraggableDirective; + batchDraggableDirective.updateInput(binding.value, binding.oldValue!); + }, + beforeUnmount(el: HTMLElement & { [props: string]: any }) { + const batchDraggableDirective = el[BatchDraggableDirective.INSTANCE_KEY] as BatchDraggableDirective; + batchDraggableDirective.ngOnDestroy?.(); + }, +}; diff --git a/packages/devui-vue/devui/dragdrop-new/src/directive-base.ts b/packages/devui-vue/devui/dragdrop-new/src/directive-base.ts new file mode 100644 index 0000000000..70a1b078eb --- /dev/null +++ b/packages/devui-vue/devui/dragdrop-new/src/directive-base.ts @@ -0,0 +1,158 @@ +import { Subscription } from 'rxjs'; +import { EventEmitter } from './preserve-next-event-emitter'; + +export interface ISimpleChange { + previousValue: any; + currentValue: any; + firstChange: boolean; +} +export class NgSimpleChange { + constructor(public previousValue: any, public currentValue: any, public firstChange: boolean) {} + isFirstChange(): boolean { + return this.firstChange; + } +} +export type NgSimpleChanges = Record; +export class NgDirectiveBase< + IInput extends { [prop: string]: any } = { [prop: string]: any }, + IOutput = { [prop: string]: (e: any) => void } +> { + private __eventListenerMap = new Map(); + mounted() { + if (this.hostBindingMap && this.el.nativeElement) { + Object.keys(this.hostBindingMap).forEach((key) => { + if ((this as any)[key] !== undefined) { + this.hostBinding(this.hostBindingMap![key], key); + } + }); + } + if (this.hostListenerMap && this.el.nativeElement) { + Object.keys(this.hostListenerMap).forEach((key) => { + if ((this as any)[key]) { + this.hostListener(this.hostListenerMap![key], key); + } + }); + } + } + el: { nativeElement: any } = { nativeElement: null }; + setInput(props: IInput & IOutput & { [props: string]: any }) { + if (!props) { + return; + } + const changes: Map = new Map(); + Object.keys(props).forEach((key) => { + if (key.startsWith('@')) { + const outputKey = this.getOutputKey(key.slice(1)); + this.eventListener(outputKey, props[key]); + } else { + const inputKey = this.getInputKey(key); + const previousValue = (this as any)[inputKey]; + (this as any)[inputKey] = props[key]; + changes.set(inputKey, { + previousValue, + currentValue: props[key], + firstChange: true, + }); + } + }); + this.notifyOnChanges(changes); + if (this.hostBindingMap && this.el.nativeElement) { + Object.keys(this.hostBindingMap).forEach((key) => { + if (props[key]) { + this.hostBinding(this.hostBindingMap![key], key); + } + }); + } + } + updateInput(props: IInput & IOutput, old: IInput & IOutput) { + const changes: Map = new Map(); + props && + Object.keys(props).forEach((key) => { + const inputKey = this.getInputKey(key); + if (props[key] !== old?.[key]) { + changes.set(inputKey, { + previousValue: old[key], + currentValue: props[key], + firstChange: old[key] === undefined, + }); + } + }); + old && + Object.keys(old) + .filter((key) => !Object.keys(props).includes(key)) + .forEach((key) => { + if (old[key] !== props?.[key]) { + const inputKey = this.getInputKey(key); + changes.set(inputKey, { + previousValue: old[key], + currentValue: props[key], + firstChange: old[key] === undefined, + }); + } + }); + changes.forEach((value, key) => { + if (key.startsWith('@')) { + this.eventListener(key.slice(1), value['currentValue']); + } else { + (this as any)[key] = value['currentValue']; + } + }); + this.notifyOnChanges(changes); + if (this.hostBindingMap && this.el.nativeElement) { + Object.keys(this.hostBindingMap).forEach((key) => { + if (changes.get(key)) { + this.hostBinding(this.hostBindingMap![key], key); + } + }); + } + } + hostBinding(key: string, valueKey: string) { + const element = this.el.nativeElement as HTMLElement; + const value = (this as any)[valueKey]; + element.setAttribute(key, value); + } + + hostListener(key: string, functionKey: string) { + const element = this.el.nativeElement as HTMLElement; + element.addEventListener(key, (this as any)[functionKey].bind(this)); + } + eventListener(key: string, userFunction: (e: any) => void) { + const subscription = ((this as any)[key] as EventEmitter).subscribe((e: any) => { + userFunction(e); + }); + if (this.__eventListenerMap.get(key)) { + this.__eventListenerMap.get(key)?.unsubscribe(); + this.__eventListenerMap.delete(key); + } + this.__eventListenerMap.set(key, subscription); + } + + ngOnChanges?(changes: NgSimpleChanges): void; + hostBindingMap?: { [key: string]: string } = undefined; + hostListenerMap?: { [key: string]: string } = undefined; + inputNameMap?: { [key: string]: string } = undefined; + outputNameMap?: { [key: string]: string } = undefined; + + getInputKey(key: string) { + return (this.inputNameMap && this.inputNameMap[key]) || key; + } + + getOutputKey(key: string) { + return (this.outputNameMap && this.outputNameMap[key]) || key; + } + + notifyOnChanges(changes: Map) { + if (this.ngOnChanges) { + const simpleChanges = [...changes.entries()] + .filter(([key, value]) => !key.startsWith('@')) + .reduce((obj: NgSimpleChanges, [key, value]) => { + const { previousValue, currentValue, firstChange } = value; + obj[key] = new NgSimpleChange(previousValue, currentValue, firstChange); + return obj; + }, {}); + if (Object.keys(simpleChanges).length) { + this.ngOnChanges(simpleChanges); + } + } + } +} diff --git a/packages/devui-vue/devui/dragdrop-new/src/drag-drop.service.ts b/packages/devui-vue/devui/dragdrop-new/src/drag-drop.service.ts new file mode 100644 index 0000000000..57e55b41d3 --- /dev/null +++ b/packages/devui-vue/devui/dragdrop-new/src/drag-drop.service.ts @@ -0,0 +1,316 @@ +import { Subject, Subscription } from 'rxjs'; +import { DraggableDirective } from './draggable.directive'; +import { Utils } from './utils'; +import { InjectionKey, provide } from 'vue'; +import { DragPreviewDirective } from './drag-preview.directive'; +import { DragDropTouch } from './touch-support/dragdrop-touch'; + +export class DragDropService { + static TOKEN: InjectionKey = Symbol('DRAG_DROP_SERVICE_TOKEN'); + dragData: any; + draggedEl: any; + draggedElIdentity: any; + batchDragData?: Array<{ + identity?: any; + draggable: DraggableDirective; + dragData: any; + }>; + batchDragGroup?: string; + batchDragStyle?: Array; + batchDragging?: boolean; + scope?: string | Array; + dropTargets: Array<{ nativeElement: any }> = []; + dropEvent: Subject = new Subject(); + dragEndEvent = new Subject(); + dragStartEvent = new Subject(); + dropOnItem?: boolean; + dragFollow?: boolean; + dragFollowOptions?: { + appendToBody?: boolean; + }; + dropOnOrigin?: boolean; + draggedElFollowingMouse?: boolean; + dragOffset?: { + top: number; + left: number; + offsetLeft: number | null; + offsetTop: number | null; + width?: number; + height?: number; + }; + subscription: Subscription = new Subscription(); + + private _dragEmptyImage?: HTMLImageElement; + get dragEmptyImage() { + if (!this._dragEmptyImage) { + this._dragEmptyImage = new Image(); + // safari的img必须要有src + this._dragEmptyImage.src = + 'data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQImWNgYGBgAAAABQABh6FO1AAAAABJRU5ErkJggg=='; + } + return this._dragEmptyImage; + } + + dragCloneNode: any; + dragOriginPlaceholder: any; + dragItemContainer: any; + dragItemParentName = ''; + dragItemChildrenName = ''; + intersectionObserver: any = null; + sub?: Subscription; + dragOriginPlaceholderNextSibling: any; + touchInstance: any; + + /* 协同拖拽需要 */ + dragElShowHideEvent = new Subject(); + dragSyncGroupDirectives?: any; + /* 预览功能 */ + dragPreviewDirective?: DragPreviewDirective; + get document() { + return window.document; + } + constructor() { + this.touchInstance = DragDropTouch.getInstance(); + } + + newSubscription() { + this.subscription.unsubscribe(); + // eslint-disable-next-line no-return-assign + return (this.subscription = new Subscription()); + } + + enableDraggedCloneNodeFollowMouse() { + if (!this.dragCloneNode) { + this.dragItemContainer = this.draggedEl.parentElement; + if (this.dragPreviewDirective && this.dragPreviewDirective.dragPreviewTemplate) { + this.dragPreviewDirective.createPreview(); + this.dragCloneNode = this.dragPreviewDirective.getPreviewElement(); + this.dragItemContainer = this.document.body; + } else { + this.dragCloneNode = this.draggedEl.cloneNode(true); + } + + this.dragCloneNode.style.margin = '0'; + if (this.dragFollowOptions && this.dragFollowOptions.appendToBody) { + this.dragItemContainer = this.document.body; + this.copyStyle(this.draggedEl, this.dragCloneNode); + } + + if (this.dragItemChildrenName !== '') { + const parentElement = this.dragItemParentName === '' ? this.dragCloneNode : this.document.querySelector(this.dragItemParentName); + const dragItemChildren = parentElement.querySelectorAll(this.dragItemChildrenName); + this.interceptChildNode(parentElement, dragItemChildren); + } + // 拷贝canvas的内容 + const originCanvasArr = this.draggedEl.querySelectorAll('canvas'); + const targetCanvasArr = this.dragCloneNode.querySelectorAll('canvas'); + [].forEach.call(targetCanvasArr, (canvas: HTMLCanvasElement, index: number) => { + canvas.getContext('2d')!.drawImage(originCanvasArr[index], 0, 0); + }); + + this.document.addEventListener('dragover', this.followMouse4CloneNode, { capture: true, passive: true }); + + this.dragCloneNode.style.width = this.dragOffset!.width + 'px'; + this.dragCloneNode.style.height = this.dragOffset!.height + 'px'; + + if ( + !( + this.dragPreviewDirective && + this.dragPreviewDirective.dragPreviewTemplate && + this.dragPreviewDirective.dragPreviewOptions && + this.dragPreviewDirective.dragPreviewOptions.skipBatchPreview + ) + ) { + // 批量拖拽样式 + if (this.batchDragging && this.batchDragData && this.batchDragData.length > 1) { + // 创建一个节点容器 + const node = this.document.createElement('div'); + node.appendChild(this.dragCloneNode); + node.classList.add('batch-dragged-node'); + + /* 计数样式定位 */ + if (this.batchDragStyle && this.batchDragStyle.length && this.batchDragStyle.indexOf('badge') > -1) { + const badge = this.document.createElement('div'); + badge.innerText = String(this.batchDragData.length); + badge.classList.add('batch-dragged-node-count'); + node.style.position = 'relative'; + const style = { + position: 'absolute', + right: '5px', + top: '-12px', + height: '24px', + width: '24px', + borderRadius: '12px', + fontSize: '14px', + lineHeight: '24px', + textAlign: 'center', + color: '#fff', + background: ['#5170ff', 'var(--brand-1, #5170ff)'], + }; + Utils.addElStyles(badge, style); + node.appendChild(badge); + } + + /* 层叠感样式定位 */ + if (this.batchDragStyle && this.batchDragStyle.length && this.batchDragStyle.indexOf('stack') > -1) { + let stack = 2; + if (this.batchDragData.length === 2) { + stack = 1; + } + for (let i = 0; i < stack; i++) { + const stackNode = this.dragCloneNode.cloneNode(false); + const stackStyle = { + position: 'absolute', + left: -5 * (i + 1) + 'px', + top: -5 * (i + 1) + 'px', + zIndex: String(-(i + 1)), + width: this.dragOffset!.width + 'px', + height: this.dragOffset!.height + 'px', + background: '#fff', + border: ['1px solid #5170ff', '1px solid var(--brand-1, #5170ff)'], + }; + Utils.addElStyles(stackNode, stackStyle); + node.appendChild(stackNode); + } + } + this.dragCloneNode = node; + } + } + + this.dragCloneNode.classList.add('drag-clone-node'); + if (!(this.dragPreviewDirective && this.dragPreviewDirective.dragPreviewTemplate)) { + this.dragCloneNode.style.width = this.dragOffset!.width + 'px'; + this.dragCloneNode.style.height = this.dragOffset!.height + 'px'; + } + this.dragCloneNode.style.position = 'fixed'; + this.dragCloneNode.style.zIndex = '1090'; + this.dragCloneNode.style.pointerEvents = 'none'; + this.dragCloneNode.style.top = this.dragOffset!.top + 'px'; + this.dragCloneNode.style.left = this.dragOffset!.left + 'px'; + this.dragCloneNode.style.willChange = 'left, top'; + this.dragItemContainer.appendChild(this.dragCloneNode); + + setTimeout(() => { + if (this.draggedEl) { + this.draggedEl.style.display = 'none'; + this.dragElShowHideEvent.next(false); + if (this.dragOriginPlaceholder) { + this.dragOriginPlaceholder.style.display = 'block'; + } + } + }); + } + } + + disableDraggedCloneNodeFollowMouse() { + if (this.dragCloneNode) { + this.document.removeEventListener('dragover', this.followMouse4CloneNode, { capture: true }); + this.dragItemContainer.removeChild(this.dragCloneNode); + this.draggedEl.style.display = ''; + this.dragElShowHideEvent.next(true); + } + if (this.dragPreviewDirective && this.dragPreviewDirective.dragPreviewTemplate) { + this.dragPreviewDirective.destroyPreview(); + } + this.dragCloneNode = undefined; + this.dragItemContainer = undefined; + + if (this.intersectionObserver) { + this.intersectionObserver.disconnect(); + } + } + + interceptChildNode(parentNode: Node, childNodeList: NodeListOf) { + const interceptOptions = { + root: parentNode, + } as any; + this.intersectionObserver = new IntersectionObserver(this.setChildNodeHide, interceptOptions); + [].forEach.call(childNodeList, (childNode) => { + this.intersectionObserver.observe(childNode); + }); + } + + setChildNodeHide(entries: any) { + entries.forEach((element: any) => { + const { isIntersecting, target: childNode } = element; + if (isIntersecting) { + childNode.style.display = 'block'; + } else { + childNode.style.display = 'none'; + } + }); + } + + followMouse4CloneNode = (event: DragEvent) => { + const { offsetLeft, offsetTop } = this.dragOffset!; + const { clientX, clientY } = event; + requestAnimationFrame(() => { + if (!this.dragCloneNode) { + return; + } + this.dragCloneNode.style.left = clientX - offsetLeft! + 'px'; + this.dragCloneNode.style.top = clientY - offsetTop! + 'px'; + }); + }; + + getBatchDragData(identity?: any, order: ((a: any, b: any) => number) | 'select' | 'draggedElFirst' = 'draggedElFirst') { + const result = this.batchDragData!.map((dragData) => dragData.dragData); + if (typeof order === 'function') { + result.sort(<(a: any, b: any) => number>order); + } else if (order === 'draggedElFirst') { + let dragData = this.dragData; + if (identity) { + const realDragData = this.batchDragData!.filter((dd) => dd.identity === identity).pop()!.dragData; + dragData = realDragData; + } + result.splice(result.indexOf(dragData), 1); + result.splice(0, 0, dragData); + } + return result; + } + + /** usage: + * constructor(..., private dragDropService: DragDropService) {} + * cleanBatchDragData() { this.dragDropService.cleanBatchDragData(); } + */ + public cleanBatchDragData() { + const batchDragData = this.batchDragData; + if (this.batchDragData) { + this.batchDragData + .filter((dragData) => dragData.draggable) + .map((dragData) => dragData.draggable) + .forEach((draggable) => { + draggable.batchDraggable.dragData = undefined; + }); + this.batchDragData = undefined; + this.batchDragGroup = undefined; + } + return batchDragData; + } + + public copyStyle(source: HTMLElement, target: HTMLElement) { + ['id', 'class', 'style', 'draggable'].forEach(function (att) { + target.removeAttribute(att); + }); + + // copy style (without transitions) + const computedStyle = getComputedStyle(source); + for (let i = 0; i < computedStyle.length; i++) { + const key = computedStyle[i] as any; + if (key.indexOf('transition') < 0) { + target.style[key] = computedStyle[key]; + } + } + target.style.pointerEvents = 'none'; + // and repeat for all children + for (let i = 0; i < source.children.length; i++) { + this.copyStyle(source.children[i] as HTMLElement, target.children[i] as HTMLElement); + } + } +} + +export function useDragDropService() { + const dragDropService = new DragDropService(); + provide(DragDropService.TOKEN, new DragDropService()); + return dragDropService; +} diff --git a/packages/devui-vue/devui/dragdrop-new/src/drag-preview-clone-dom-ref.component.tsx b/packages/devui-vue/devui/dragdrop-new/src/drag-preview-clone-dom-ref.component.tsx new file mode 100644 index 0000000000..a1ae6d1ac8 --- /dev/null +++ b/packages/devui-vue/devui/dragdrop-new/src/drag-preview-clone-dom-ref.component.tsx @@ -0,0 +1,111 @@ +import { DragDropService } from './drag-drop.service'; +import { NgDirectiveBase, NgSimpleChanges } from './directive-base'; +import { + PropType, + defineComponent, + getCurrentInstance, + inject, + nextTick, + onBeforeUnmount, + onMounted, + onUnmounted, + onUpdated, + watch, +} from 'vue'; + +export interface IDragPreviewCloneDomRefBinding { + domRef?: HTMLElement; + copyStyle?: boolean; + [props: string]: any; +} + +export class DragPreviewCloneDomRefComponent extends NgDirectiveBase { + static NAME = 'd-drag-preview-clone-dom-ref'; + domRef: HTMLElement; + copyStyle = true; + cloneNode; + constructor(public el: { nativeElement: any }, private dragDropService: DragDropService) { + super(); + } + ngAfterViewInit() { + if (!this.cloneNode) { + this.createView(); + } + } + ngOnChanges(changes: NgSimpleChanges) { + if (changes['domRef']) { + if (this.cloneNode) { + this.destroyView(); + this.createView(); + } else { + this.createView(); + } + } + } + ngOnDestroy() { + if (this.cloneNode) { + this.destroyView(); + } + } + + createView() { + if (this.domRef) { + this.cloneNode = this.domRef.cloneNode(true); + if (this.copyStyle) { + this.dragDropService.copyStyle(this.domRef, this.cloneNode); + } + setTimeout(() => { + this.el.nativeElement.appendChild(this.cloneNode); + }, 0); + } + } + destroyView() { + if (this.cloneNode) { + if (this.el.nativeElement.contains(this.cloneNode)) { + this.el.nativeElement.removeChild(this.cloneNode); + } + this.cloneNode = undefined; + } + } + + public updateTemplate() { + // do nothing 保持api一致 + } +} +export default defineComponent({ + name: 'DDragPreviewCloneDomRef', + props: { + domRef: Object as PropType, + copyStyle: { + type: Boolean, + default: true, + }, + }, + setup(props, { expose }) { + const el: { nativeElement: any } = { nativeElement: null }; + const dragDropService = inject(DragDropService.TOKEN); + const instance = new DragPreviewCloneDomRefComponent(el, dragDropService!); + + instance.setInput(props as any); + onMounted(() => { + instance.mounted(); + nextTick(() => { + instance.ngAfterViewInit?.(); + }); + }); + watch( + () => props, + (binding, oldBinding) => { + instance.updateInput(binding as any, oldBinding as any); + } + ); + onBeforeUnmount(() => { + instance.ngOnDestroy?.(); + }); + + expose({ + instance, + }); + return () =>
    (el.nativeElement = e)}>
    ; + }, +}); diff --git a/packages/devui-vue/devui/dragdrop-new/src/drag-preview.component.tsx b/packages/devui-vue/devui/dragdrop-new/src/drag-preview.component.tsx new file mode 100644 index 0000000000..a0ea370799 --- /dev/null +++ b/packages/devui-vue/devui/dragdrop-new/src/drag-preview.component.tsx @@ -0,0 +1,35 @@ +import { PropType, VNodeChild, defineComponent } from 'vue'; + +export interface IDragPreviewContext { + data: any; // dragPreviewData + draggedEl: HTMLElement; + dragData: any; + batchDragData?: any[]; + dragSyncDOMElements?: HTMLElement[]; +} + +export interface IDragPreviewTemplate { + template: (context: IDragPreviewContext) => VNodeChild; +} +export const DragPreviewTemplate = defineComponent({ + name: 'DDragPreviewTemplate', + setup(props, { slots, expose }) { + expose({ + template: slots.default, + } as IDragPreviewTemplate); + + return () => null; + }, +}); + +export const DragPreviewComponent = defineComponent({ + name: 'DDragPreviewContainer', + props: { + template: Function as PropType<(context: IDragPreviewContext) => VNodeChild>, + context: Object as PropType, + }, + setup(props) { + return () => props.template?.(props.context!); + }, +}); +export default DragPreviewComponent; diff --git a/packages/devui-vue/devui/dragdrop-new/src/drag-preview.directive.ts b/packages/devui-vue/devui/dragdrop-new/src/drag-preview.directive.ts new file mode 100644 index 0000000000..9ff21c3436 --- /dev/null +++ b/packages/devui-vue/devui/dragdrop-new/src/drag-preview.directive.ts @@ -0,0 +1,88 @@ +import { InjectionKey, createApp, DirectiveBinding, getCurrentInstance, createBaseVNode, render } from 'vue'; +import { NgDirectiveBase } from './directive-base'; +import { DragDropService } from './drag-drop.service'; +import DragPreview, { IDragPreviewTemplate } from './drag-preview.component'; +import { injectFromContext, provideToContext } from './utils'; + +export interface IDragPreviewBinding { + dragPreview?: IDragPreviewTemplate; + dragPreviewData?: any; + dragPreviewOptions?: { + skipBatchPreview: boolean; + }; + [props: string]: any; + context; +} +export class DragPreviewDirective extends NgDirectiveBase { + static INSTANCE_KEY = '__vueDevuiDragPreviewDirectiveInstance'; + static TOKEN: InjectionKey = Symbol('DRAG_PREVIEW_DIRECTIVE_TOKEN'); + inputNameMap?: { [key: string]: string } | undefined = { + dragPreview: 'dragPreviewTemplate', + }; + dragPreviewTemplate: IDragPreviewTemplate; + dragPreviewData; + dragPreviewOptions = { + skipBatchPreview: false, + }; + public previewRef; + public context; + el: { nativeElement: any } = { nativeElement: null }; + constructor(el: HTMLElement, private dragDropService: DragDropService) { + super(); + this.el.nativeElement = el; + } + + public createPreview() { + const context = { + data: this.dragPreviewData, + draggedEl: this.dragDropService.draggedEl, + dragData: this.dragDropService.dragData, + batchDragData: this.dragDropService.batchDragData && this.dragDropService.getBatchDragData(), + dragSyncDOMElements: this.dragDropService.dragSyncGroupDirectives && this.getDragSyncDOMElements(), + }; + const app = createApp(DragPreview, { context, template: this.dragPreviewTemplate?.template }); + // 这里用hack的手法来讲当前的上下文川给新的模板组件 + app._context.provides = Object.create(this.context); + const element = document.createElement('div'); + const instance = app.mount(element); + const unmount = () => { + app.unmount(); + }; + + this.previewRef = { + instance, + element, + unmount, + }; + } + + public destroyPreview() { + if (this.previewRef) { + this.previewRef.unmount(); + this.previewRef = undefined; + } + } + + public getPreviewElement() { + return this.previewRef && this.previewRef.element; + } + private getDragSyncDOMElements() { + return this.dragDropService.dragSyncGroupDirectives.map((dir) => dir.el.nativeElement); + } +} + +export default { + mounted(el: HTMLElement & { [props: string]: any }, binding: DirectiveBinding, vNode) { + const context = vNode['ctx'].provides; + const dragDropService = injectFromContext(DragDropService.TOKEN, context) as DragDropService; + const dragPreviewDirective = (el[DragPreviewDirective.INSTANCE_KEY] = new DragPreviewDirective(el, dragDropService)); + provideToContext(DragPreviewDirective.TOKEN, dragPreviewDirective, context); + dragPreviewDirective.setInput({ context }); + dragPreviewDirective.setInput(binding.value); + dragPreviewDirective.mounted(); + }, + updated(el: HTMLElement & { [props: string]: any }, binding: DirectiveBinding) { + const dragPreviewDirective = el[DragPreviewDirective.INSTANCE_KEY] as DragPreviewDirective; + dragPreviewDirective.updateInput(binding.value, binding.oldValue!); + }, +}; diff --git a/packages/devui-vue/devui/dragdrop-new/src/draggable.directive.ts b/packages/devui-vue/devui/dragdrop-new/src/draggable.directive.ts new file mode 100644 index 0000000000..cdf662a55e --- /dev/null +++ b/packages/devui-vue/devui/dragdrop-new/src/draggable.directive.ts @@ -0,0 +1,532 @@ +import { Subject, Subscription, fromEvent } from 'rxjs'; +import { EventEmitter, PreserveNextEventEmitter } from './preserve-next-event-emitter'; +import { DragDropService } from './drag-drop.service'; +import { Utils, injectFromContext, provideToContext } from './utils'; +import { DirectiveBinding, InjectionKey, VNode } from 'vue'; +import { NgDirectiveBase } from './directive-base'; +import { DragPreviewDirective } from './drag-preview.directive'; + +export interface IDraggableBinding { + dragData?: any; + dragHandle?: string; + dragEffect?: string; + dragScope?: string | Array; + dragHandleClass?: string; + dragOverClass?: string; + disabled?: boolean; + enableDragFollow?: boolean; + dragFollowOptions?: { + appendToBody?: boolean; + }; + originPlaceholder?: { + show?: boolean; + tag?: string; + style?: { [cssProperties: string]: string }; + text?: string; + removeDelay?: number; // 单位: ms + }; + dragIdentity?: any; + dragItemParentName?: string; + dragItemChildrenName?: string; +} +export interface IDraggableListener { + '@dragStartEvent'?: (_: any) => void; + '@dragEvent'?: (_: any) => void; + '@dragEndEvent'?: (_: any) => void; + '@dropEndEvent'?: (_: any) => void; +} + +export class DraggableDirective extends NgDirectiveBase { + static INSTANCE_KEY = '__vueDevuiDraggableDirectiveInstance'; + static TOKEN: InjectionKey = Symbol('DRAGGABLE_DIRECTIVE_TOKEN'); + hostBindingMap?: { [key: string]: string } | undefined = { + draggable: 'draggable', + 'data-drag-handle-selector': 'dragHandle', + }; + draggable: boolean = true; + dragData?: any; + dragScope: string | Array = 'default'; + dragHandle?: string; + dragHandleClass = 'drag-handle'; + dragOverClass?: string; + dragEffect = 'move'; + public get disabled(): boolean { + return this._disabled; + } + + public set disabled(value: boolean) { + this._disabled = value; + this.draggable = !this._disabled; + } + private _disabled: boolean = false; + + dragStartEvent: EventEmitter = new EventEmitter(); + dragEvent: PreserveNextEventEmitter = new PreserveNextEventEmitter(); + dragEndEvent: EventEmitter = new EventEmitter(); + dropEndEvent: PreserveNextEventEmitter = new PreserveNextEventEmitter(); + document = window.document; + private mouseOverElement: any; + + enableDragFollow = false; // 默认false使用浏览器H5API拖拽, 否则使用原dom定位偏移 + dragFollowOptions?: { + appendToBody?: boolean; + }; + originPlaceholder?: { + show?: boolean; + tag?: string; + style?: { [cssProperties: string]: string }; + text?: string; + removeDelay?: number; // 单位: ms + }; + dragIdentity: any; // 用于虚拟滚动的恢复 + + dragItemParentName = ''; // 当前拖拽元素的类名或元素名称(类名需要加.),主要用于子节点的截取操作 + dragItemChildrenName = ''; // 当前拖拽元素的子节点类名或元素名称(类名需要加.) + + dragsSub: Subscription = new Subscription(); + destroyDragEndSub?: Subscription = new Subscription(); + isDestroyed?: boolean; + private delayRemoveOriginPlaceholderTimer?: number; + public batchDraggable: undefined | any; + private dragOriginPlaceholder?: HTMLElement; + private dragOriginPlaceholderNextSibling?: Element; + public dragElShowHideEvent = new Subject(); + public beforeDragStartEvent = new Subject(); + + el: { nativeElement: any } = { nativeElement: null }; + dragDropService: DragDropService; + dragPreviewDirective?: DragPreviewDirective; + + constructor(el: HTMLElement, dragDropService: DragDropService, dragPreviewDirective?: DragPreviewDirective) { + super(); + this.el.nativeElement = el; + this.dragDropService = dragDropService; + this.dragPreviewDirective = dragPreviewDirective; + } + + ngOnInit() { + this.dragsSub.add(fromEvent(this.el.nativeElement, 'mouseover').subscribe((event) => this.mouseover(event))); + this.dragsSub.add(fromEvent(this.el.nativeElement, 'dragstart').subscribe((event) => this.dragStart(event))); + this.dragsSub.add(fromEvent(this.el.nativeElement, 'dragend').subscribe((event) => this.dragEnd(event))); + } + + dropSubscription() { + const dragDropSub = this.dragDropService.newSubscription(); + dragDropSub.add( + this.dragDropService.dropEvent.subscribe((event) => { + this.mouseOverElement = undefined; + Utils.removeClass(this.el.nativeElement, this.dragOverClass!); + this.dropEndEvent.emit(event); + // 兼容虚拟滚动后被销毁 + if (this.isDestroyed) { + if (this.dropEndEvent.schedulerFns && this.dropEndEvent.schedulerFns.size > 0) { + this.dropEndEvent.forceCallback(event, true); + } + } + if (this.dragDropService.dragOriginPlaceholder) { + if (this.originPlaceholder && this.originPlaceholder.removeDelay! > 0 && !this.dragDropService.dropOnOrigin) { + // 非drop到自己的情况 + this.delayRemoveOriginPlaceholder(); + } else { + this.removeOriginPlaceholder(); + } + this.dragDropService.draggedElIdentity = undefined; + } + this.dragDropService.subscription.unsubscribe(); + }) + ); + dragDropSub.add(this.dragDropService.dragElShowHideEvent.subscribe(this.dragElShowHideEvent)); + } + + ngAfterViewInit() { + this.applyDragHandleClass(); + if (this.dragIdentity) { + if (this.dragDropService.draggedEl && this.dragIdentity === this.dragDropService.draggedElIdentity) { + if (this.originPlaceholder && this.originPlaceholder.show !== false) { + this.insertOriginPlaceholder(); + } + this.dragDropService.draggedEl = this.el.nativeElement; + this.el.nativeElement.style.display = 'none'; // recovery don't need to emit event + } + } + } + + ngOnDestroy() { + // 兼容虚拟滚动后被销毁 + this.isDestroyed = true; + if (this.dragDropService.draggedEl === this.el.nativeElement) { + this.destroyDragEndSub = new Subscription(); + this.destroyDragEndSub.add( + fromEvent(this.el.nativeElement, 'dragend').subscribe((event) => { + this.dragEnd(event); + if (this.dropEndEvent.schedulerFns && this.dropEndEvent.schedulerFns.size > 0) { + this.dropEndEvent.forceCallback(event, true); + } + this.destroyDragEndSub!.unsubscribe(); + this.destroyDragEndSub = undefined; + }) + ); + if ( + this.originPlaceholder && + this.originPlaceholder.show !== false && + this.dragDropService.dragOriginPlaceholder && + this.dragDropService.draggedElIdentity + ) { + // 如果有originPlaceholder 则销毁 + this.removeOriginPlaceholder(); + } + } + this.dragsSub.unsubscribe(); + } + + dragStart(e: DragEvent) { + if (this.allowDrag(e)) { + Utils.addClass(this.el.nativeElement, this.dragOverClass!); + this.dragDropService.dragData = this.dragData; + this.dragDropService.scope = this.dragScope; + this.dragDropService.draggedEl = this.el.nativeElement; + this.dragDropService.draggedElIdentity = this.dragIdentity; + this.dragDropService.dragFollow = this.enableDragFollow; + this.dragDropService.dragFollowOptions = this.dragFollowOptions; + this.dragDropService.dragItemParentName = this.dragItemParentName; + this.dragDropService.dragItemChildrenName = this.dragItemChildrenName; + this.beforeDragStartEvent.next(true); + if (this.dragPreviewDirective && this.dragPreviewDirective?.dragPreviewTemplate) { + this.dragDropService.dragFollow = true; + this.dragDropService.dragPreviewDirective = this.dragPreviewDirective; + } + if (this.batchDraggable) { + if (this.batchDraggable.dragData) { + // 有dragData证明被加入到了group里 + if (this.dragDropService.batchDragData && this.dragDropService.batchDragData.length > 1) { + this.dragDropService.batchDragging = true; + this.dragDropService.batchDragStyle = this.batchDraggable.batchDragStyle; + } + } else if (this.batchDraggable.batchDragLastOneAutoActiveEventKeys) { + const batchActiveAble = this.batchDraggable.batchDragLastOneAutoActiveEventKeys + .map((key: any) => (e as any)[key]) + .some((eventKey: any) => eventKey === true); + if (batchActiveAble) { + if (this.dragDropService.batchDragData && this.dragDropService.batchDragData.length > 0) { + this.batchDraggable.active(); + if (!this.batchDraggable.dragData) { + // 如果用户没做任何处理把项目加到组里则加到组里 + this.batchDraggable.addToBatchGroup(); + } + if (this.dragDropService.batchDragData.some((dragData) => dragData.draggable === this)) { + this.dragDropService.batchDragging = true; + this.dragDropService.batchDragStyle = this.batchDraggable.batchDragStyle; + } + } + } + } + } + const targetOffset = this.el.nativeElement.getBoundingClientRect(); + if (this.dragDropService.dragFollow) { + const mousePositionXY = this.mousePosition(e); + // 用于出现transform的场景position:fixed相对位置变更 + const transformOffset = this.checkAndGetViewPointChange(this.el.nativeElement); + this.dragDropService.dragOffset = { + left: targetOffset.left, + top: targetOffset.top, + offsetLeft: mousePositionXY.x - targetOffset.left + transformOffset!.offsetX, + offsetTop: mousePositionXY.y - targetOffset.top + transformOffset!.offsetY, + width: targetOffset.width, + height: targetOffset.height, + }; + this.dragDropService.enableDraggedCloneNodeFollowMouse(); + } else { + this.dragDropService.dragOffset = { + left: targetOffset.left, + top: targetOffset.top, + offsetLeft: null, + offsetTop: null, + width: targetOffset.width, + height: targetOffset.height, + }; + } + if (this.originPlaceholder && this.originPlaceholder.show !== false) { + this.insertOriginPlaceholder(false); + } + if (this.dragDropService.batchDragging && this.dragDropService.batchDragData && this.dragDropService.batchDragData.length > 1) { + this.dragDropService.batchDragData + .map((dragData) => dragData.draggable) + .filter((draggable) => draggable && draggable !== this) + .forEach((draggable) => { + if (draggable.originPlaceholder && draggable.originPlaceholder.show !== false) { + draggable.insertOriginPlaceholder(true, false); + draggable.el.nativeElement.style.display = 'none'; + } else { + setTimeout(() => { + draggable.el.nativeElement.style.display = 'none'; + }); + } + }); + } + // Firefox requires setData() to be called otherwise the drag does not work. + if (e.dataTransfer !== null) { + e.dataTransfer.setData('text', ''); + } + e.dataTransfer!.effectAllowed = this.dragEffect as unknown as DataTransfer['effectAllowed']; + this.dropSubscription(); + if (this.dragDropService.dragFollow) { + if (typeof DataTransfer.prototype.setDragImage === 'function') { + e.dataTransfer!.setDragImage(this.dragDropService.dragEmptyImage, 0, 0); + } else { + // 兼容老浏览器 + (e.srcElement! as HTMLElement).style.display = 'none'; + this.dragDropService.dragElShowHideEvent.next(false); + } + } + e.stopPropagation(); + this.dragStartEvent.emit(e); + this.dragDropService.dragStartEvent.next(e); + } else { + e.preventDefault(); + } + } + + dragEnd(e: DragEvent) { + Utils.removeClass(this.el.nativeElement, this.dragOverClass!); + this.dragDropService.dragEndEvent.next(e); + this.mouseOverElement = undefined; + if (this.dragDropService.draggedEl) { + // 当dom被清除的的时候不会触发dragend,所以清理工作部分交给了drop,但是内部排序的时候dom不会被清理,dragend防止和drop重复操作清理动作 + if (this.dragDropService.dragFollow) { + this.dragDropService.disableDraggedCloneNodeFollowMouse(); + } + if (this.dragDropService.dragOriginPlaceholder) { + this.removeOriginPlaceholder(); + } + if (this.dragDropService.batchDragging && this.dragDropService.batchDragData && this.dragDropService.batchDragData.length > 1) { + this.dragDropService.batchDragData + .map((dragData) => dragData.draggable) + .filter((draggable) => draggable && draggable !== this) + .forEach((draggable) => { + if (draggable.originPlaceholder && draggable.originPlaceholder.show !== false) { + draggable.el.nativeElement.style.display = ''; + draggable.removeOriginPlaceholder(); + } else { + draggable.el.nativeElement.style.display = ''; + } + }); + } + if (this.batchDraggable && !this.batchDraggable.batchDragActive) { + this.batchDraggable.removeFromBatchGroup(); + this.dragDropService.batchDragging = false; + this.dragDropService.batchDragStyle = undefined; + } + if (this.dragDropService.subscription) { + this.dragDropService.subscription.unsubscribe(); + } + this.dragDropService.dragData = undefined; + this.dragDropService.scope = undefined; + this.dragDropService.draggedEl = undefined; + this.dragDropService.dragFollow = undefined; + this.dragDropService.dragFollowOptions = undefined; + this.dragDropService.dragOffset = undefined; + this.dragDropService.draggedElIdentity = undefined; + this.dragDropService.dragPreviewDirective = undefined; + } + e.stopPropagation(); + e.preventDefault(); + this.dragEndEvent.emit(e); + } + + mouseover(e: MouseEvent) { + this.mouseOverElement = e.target; + } + + private allowDrag(e: DragEvent & { fromTouch?: boolean }) { + if (!this.draggable) { + return false; + } + if (this.batchDraggable && !this.batchDraggable.allowAddToBatchGroup()) { + // 批量拖拽判断group是否相同 + return false; + } + if (this.dragHandle) { + if (e && e.fromTouch) { + return true; + } // from touchstart dispatch event + if (!this.mouseOverElement) { + return false; + } + return Utils.matches(this.mouseOverElement, this.dragHandle); + } else { + return true; + } + } + + private applyDragHandleClass() { + const dragElement = this.getDragHandleElement(); + if (!dragElement) { + return; + } + if (this.draggable) { + Utils.addClass(dragElement, this.dragHandleClass); + } else { + Utils.removeClass(this.el, this.dragHandleClass); + } + } + + private getDragHandleElement() { + let dragElement = this.el; + if (this.dragHandle) { + dragElement = this.el.nativeElement.querySelector(this.dragHandle); + } + return dragElement; + } + + private mousePosition(event: MouseEvent) { + return { + x: event.clientX, + y: event.clientY, + }; + } + public insertOriginPlaceholder = (directShow = true, updateService = true) => { + if (this.delayRemoveOriginPlaceholderTimer) { + clearTimeout(this.delayRemoveOriginPlaceholderTimer); + this.delayRemoveOriginPlaceholderTimer = undefined; + } + + const node = this.document.createElement(this.originPlaceholder?.tag || 'div'); + const rect = this.el.nativeElement.getBoundingClientRect(); + if (directShow) { + node.style.display = 'block'; + } else { + node.style.display = 'none'; + } + + node.style.width = rect.width + 'px'; + node.style.height = rect.height + 'px'; + node.classList.add('drag-origin-placeholder'); + if (this.originPlaceholder?.text) { + node.innerText = this.originPlaceholder.text; + } + if (this.originPlaceholder?.style) { + Utils.addElStyles(node, this.originPlaceholder.style); + } + if (updateService) { + this.dragDropService.dragOriginPlaceholder = node; + this.dragDropService.dragOriginPlaceholderNextSibling = this.el.nativeElement.nextSibling; + } else { + node.classList.add('side-drag-origin-placeholder'); + const originCloneNode = this.el.nativeElement.cloneNode(true); + originCloneNode.style.margin = 0; + originCloneNode.style.pointerEvents = 'none'; + originCloneNode.style.opacity = '0.3'; + node.appendChild(originCloneNode); + } + this.dragOriginPlaceholder = node; + this.dragOriginPlaceholderNextSibling = this.el.nativeElement.nextSibling; + this.el.nativeElement.parentElement.insertBefore(node, this.el.nativeElement.nextSibling); + }; + + public removeOriginPlaceholder = (updateService = true) => { + if (this.dragOriginPlaceholder) { + this.dragOriginPlaceholder.parentElement?.removeChild(this.dragOriginPlaceholder); + } + if (updateService) { + this.dragDropService.dragOriginPlaceholder = undefined; + this.dragDropService.dragOriginPlaceholderNextSibling = undefined; + } + this.dragOriginPlaceholder = undefined; + this.dragOriginPlaceholderNextSibling = undefined; + }; + public delayRemoveOriginPlaceholder = (updateService = true) => { + const timeout = this.originPlaceholder?.removeDelay; + const delayOriginPlaceholder = this.dragOriginPlaceholder; + const dragOriginPlaceholderNextSibling = this.findNextSibling(this.dragOriginPlaceholderNextSibling!); + + // 需要临时移动位置,保证被ngFor刷新之后位置是正确的 + // ngFor刷新的原理是有变化的部分都刷新,夹在变化部分中间的内容将被刷到变化部分之后的位置,所以需要恢复位置 + // setTimeout是等ngFor的View刷新, 后续需要订阅sortContainer的view的更新才需要重新恢复位置 + if (delayOriginPlaceholder?.parentElement?.contains(dragOriginPlaceholderNextSibling)) { + delayOriginPlaceholder.parentElement.insertBefore(delayOriginPlaceholder, dragOriginPlaceholderNextSibling); + } + setTimeout(() => { + if (delayOriginPlaceholder?.parentElement?.contains(dragOriginPlaceholderNextSibling)) { + delayOriginPlaceholder.parentElement.insertBefore(delayOriginPlaceholder, dragOriginPlaceholderNextSibling); + } + delayOriginPlaceholder?.classList.add('delay-deletion'); + this.delayRemoveOriginPlaceholderTimer = setTimeout(() => { + delayOriginPlaceholder?.parentElement?.removeChild(delayOriginPlaceholder); + if (this.document.body.contains(this.el.nativeElement)) { + this.el.nativeElement.style.display = ''; + this.dragDropService.dragElShowHideEvent.next(false); + } + }, timeout) as unknown as number; + if (updateService) { + this.dragDropService.dragOriginPlaceholder = undefined; + this.dragDropService.dragOriginPlaceholderNextSibling = undefined; + } + this.dragOriginPlaceholder = undefined; + this.dragOriginPlaceholderNextSibling = undefined; + }); + }; + findNextSibling(currentNextSibling: Element) { + if (!this.dragDropService.batchDragData) { + return currentNextSibling; + } else { + if ( + this.dragDropService.batchDragData + .map((dragData) => dragData.draggable && dragData.draggable.el.nativeElement) + .indexOf(currentNextSibling) > -1 + ) { + currentNextSibling = currentNextSibling.nextSibling as Element; + } + return currentNextSibling; + } + } + + private checkAndGetViewPointChange(element: HTMLElement) { + if (!element.parentNode) { + return null; + } + // 模拟一个元素测预测位置和最终位置是否符合,如果不符合则是有transform等造成的偏移 + const elementPosition = element.getBoundingClientRect(); + const testEl = this.document.createElement('div'); + Utils.addElStyles(testEl, { + opacity: '0', + position: 'fixed', + top: elementPosition.top + 'px', + left: elementPosition.left + 'px', + width: '1px', + height: '1px', + zIndex: '-999999', + }); + element.parentNode.appendChild(testEl); + const testElPosition = testEl.getBoundingClientRect(); + element.parentNode.removeChild(testEl); + return { + offsetX: testElPosition.left - elementPosition.left, + offsetY: testElPosition.top - elementPosition.top, + }; + } +} + +export default { + mounted(el: HTMLElement & { [props: string]: any }, binding: DirectiveBinding, vNode: VNode) { + const context = vNode.ctx?.provides; + const dragDropService = injectFromContext(DragDropService.TOKEN, context) as DragDropService; + let dragPreviewDirective = injectFromContext(DragPreviewDirective.TOKEN, context) as DragPreviewDirective | undefined; + if (dragPreviewDirective?.el.nativeElement !== el) { + dragPreviewDirective = undefined; + } + const draggableDirective = (el[DraggableDirective.INSTANCE_KEY] = new DraggableDirective(el, dragDropService, dragPreviewDirective)); + provideToContext(DraggableDirective.TOKEN, draggableDirective, context); + draggableDirective.setInput(binding.value); + draggableDirective.mounted(); + draggableDirective.ngOnInit?.(); + draggableDirective.ngAfterViewInit?.(); + }, + updated(el: HTMLElement & { [props: string]: any }, binding: DirectiveBinding) { + const draggableDirective = el[DraggableDirective.INSTANCE_KEY] as DraggableDirective; + draggableDirective.updateInput(binding.value, binding.oldValue!); + }, + beforeUnmount(el: HTMLElement & { [props: string]: any }) { + const draggableDirective = el[DraggableDirective.INSTANCE_KEY] as DraggableDirective; + draggableDirective.ngOnDestroy?.(); + }, +}; diff --git a/packages/devui-vue/devui/dragdrop-new/src/drop-scroll-enhance.directive.ts b/packages/devui-vue/devui/dragdrop-new/src/drop-scroll-enhance.directive.ts new file mode 100644 index 0000000000..f02351e602 --- /dev/null +++ b/packages/devui-vue/devui/dragdrop-new/src/drop-scroll-enhance.directive.ts @@ -0,0 +1,469 @@ +import { Subscription, fromEvent, merge, tap, throttleTime } from 'rxjs'; +import { DragDropService } from './drag-drop.service'; +import { Utils, injectFromContext } from './utils'; +import { DirectiveBinding } from 'vue'; +import { NgDirectiveBase } from './directive-base'; + +// types +export type DropScrollEdgeDistancePercent = number; // 单位 px / px +export type DropScrollSpeed = number; // 单位 px/ s +export type DropScrollSpeedFunction = (x: DropScrollEdgeDistancePercent) => DropScrollSpeed; +export type DropScrollDirection = 'h' | 'v'; // 'both' 暂不支持双向滚动 +export enum DropScrollOrientation { + forward, // 进, 右/下 + backward, // 退, 左/上 +} +export interface DropScrollAreaOffset { + left?: number; + right?: number; + top?: number; + bottom?: number; + widthOffset?: number; + heightOffset?: number; +} +export type DropScrollTriggerEdge = 'left' | 'right' | 'top' | 'bottom'; + +export const DropScrollEnhanceTimingFunctionGroup = { + default: (x: number) => Math.ceil((1 - x) * 18) * 100, +}; + +export interface IDropScrollEnhancedBinding { + minSpeed?: DropScrollSpeed; + maxSpeed?: DropScrollSpeed; + responseEdgeWidth?: string | ((total: number) => string); + speedFn?: DropScrollSpeedFunction; + direction?: DropScrollDirection; + viewOffset?: { + forward?: DropScrollAreaOffset; // 仅重要边和次要边有效 + backward?: DropScrollAreaOffset; + }; + dropScrollScope?: string | Array; + backSpaceDroppable?: boolean; +} +export interface IDropScrollEnhancedListener {} + +export class DropScrollEnhancedDirective extends NgDirectiveBase { + static INSTANCE_KEY = '__vueDevuiDropScrollEnhancedDirectiveInstance'; + + minSpeed: DropScrollSpeed = 50; + maxSpeed: DropScrollSpeed = 1000; + responseEdgeWidth: string | ((total: number) => string) = '100px'; + speedFn: DropScrollSpeedFunction = DropScrollEnhanceTimingFunctionGroup.default; + direction: DropScrollDirection = 'v'; + viewOffset?: { + forward?: DropScrollAreaOffset; // 仅重要边和次要边有效 + backward?: DropScrollAreaOffset; + }; + dropScrollScope?: string | Array; + backSpaceDroppable = true; + + private forwardScrollArea?: HTMLElement; + private backwardScrollArea?: HTMLElement; + private subscription: Subscription = new Subscription(); + private forwardScrollFn?: (event: DragEvent) => void; + private backwardScrollFn?: (event: DragEvent) => void; + private lastScrollTime?: number; + private animationFrameId?: number; + document: Document; + el: { nativeElement: any } = { nativeElement: null }; + private dragDropService: DragDropService; + + constructor(el: HTMLElement, dragDropService: DragDropService) { + super(); + this.el.nativeElement = el; + this.dragDropService = dragDropService; + this.document = window.document; + } + + ngAfterViewInit() { + // 设置父元素 + this.el.nativeElement.parentNode.style.position = 'relative'; + this.el.nativeElement.parentNode.style.display = 'block'; + // 创建后退前进区域和对应的滚动函数 + this.forwardScrollArea = this.createScrollArea(this.direction, DropScrollOrientation.forward); + this.backwardScrollArea = this.createScrollArea(this.direction, DropScrollOrientation.backward); + this.forwardScrollFn = this.createScrollFn(this.direction, DropScrollOrientation.forward, this.speedFn); + this.backwardScrollFn = this.createScrollFn(this.direction, DropScrollOrientation.backward, this.speedFn); + + // 拖拽到其上触发滚动 + this.subscription.add( + fromEvent(this.forwardScrollArea!, 'dragover') + .pipe( + tap((event) => { + event.preventDefault(); + event.stopPropagation(); + }), + throttleTime(100, undefined, { leading: true, trailing: false }) + ) + .subscribe((event) => this.forwardScrollFn!(event)) + ); + this.subscription.add( + fromEvent(this.backwardScrollArea!, 'dragover') + .pipe( + tap((event) => { + event.preventDefault(); + event.stopPropagation(); + }), + throttleTime(100, undefined, { leading: true, trailing: false }) + ) + .subscribe((event) => this.backwardScrollFn!(event)) + ); + // 拖拽放置委托 + this.subscription.add( + merge(fromEvent(this.forwardScrollArea, 'drop'), fromEvent(this.backwardScrollArea, 'drop')).subscribe( + (event) => this.delegateDropEvent(event) + ) + ); + // 拖拽离开清除参数 + this.subscription.add( + merge( + fromEvent(this.forwardScrollArea, 'dragleave', { passive: true }), + fromEvent(this.backwardScrollArea, 'dragleave', { passive: true }) + ).subscribe((event) => this.cleanLastScrollTime()) + ); + // 滚动过程计算区域有效性,滚动条贴到边缘的时候无效,无效的时候设置鼠标事件可用为none + this.subscription.add( + fromEvent(this.el.nativeElement, 'scroll', { passive: true }) + .pipe(throttleTime(300, undefined, { leading: true, trailing: true })) + .subscribe((event) => { + this.toggleScrollToOneEnd(this.el.nativeElement, this.forwardScrollArea!, this.direction, DropScrollOrientation.forward); + this.toggleScrollToOneEnd(this.el.nativeElement, this.backwardScrollArea!, this.direction, DropScrollOrientation.backward); + }) + ); + // 窗口缩放的时候重绘有效性区域 + this.subscription.add( + fromEvent(window, 'resize', { passive: true }) + .pipe(throttleTime(300, undefined, { leading: true, trailing: true })) + .subscribe((event) => this.resizeArea()) + ); + // dragstart的时候显示拖拽滚动边缘面板 + this.subscription.add( + this.dragDropService.dragStartEvent.subscribe(() => { + if (!this.allowScroll()) { + return; + } + setTimeout(() => { + // 立马出现会打断边缘元素的拖拽 + this.forwardScrollArea!.style.display = 'block'; + this.backwardScrollArea!.style.display = 'block'; + }); + }) + ); + // dragEnd或drop的时候结束了拖拽,滚动区域影藏起来 + this.subscription.add( + merge(this.dragDropService.dragEndEvent, this.dragDropService.dropEvent).subscribe(() => { + this.forwardScrollArea!.style.display = 'none'; + this.backwardScrollArea!.style.display = 'none'; + this.lastScrollTime = undefined; + }) + ); + setTimeout(() => { + this.resizeArea(); + }, 0); + } + + ngOnDestroy() { + this.subscription.unsubscribe(); + } + + createScrollFn(direction: DropScrollDirection, orientation: DropScrollOrientation, speedFn: DropScrollSpeedFunction) { + if (typeof window === 'undefined') { + return; + } + const scrollAttr = direction === 'v' ? 'scrollTop' : 'scrollLeft'; + const eventAttr = direction === 'v' ? 'clientY' : 'clientX'; + const scrollWidthAttr = direction === 'v' ? 'scrollHeight' : 'scrollWidth'; + const offsetWidthAttr = direction === 'v' ? 'offsetHeight' : 'offsetWidth'; + const clientWidthAttr = direction === 'v' ? 'clientHeight' : 'clientWidth'; + const rectWidthAttr = direction === 'v' ? 'height' : 'width'; + const compareTarget = orientation === DropScrollOrientation.forward ? this.forwardScrollArea : this.backwardScrollArea; + const targetAttr = this.getCriticalEdge(direction, orientation); + const scrollElement = this.el.nativeElement; + + return (event: DragEvent) => { + const compareTargetRect = compareTarget!.getBoundingClientRect(); + const distance = event[eventAttr] - compareTargetRect[targetAttr]; + let speed = speedFn(Math.abs(distance / (compareTargetRect[rectWidthAttr] || 1))); + if (speed < this.minSpeed) { + speed = this.minSpeed; + } + if (speed > this.maxSpeed) { + speed = this.maxSpeed; + } + if (distance < 0) { + speed = -speed; + } + if (this.animationFrameId) { + window.cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = undefined; + } + this.animationFrameId = requestAnimationFrame(() => { + const time = new Date().getTime(); + const moveDistance = Math.ceil((speed * (time - (this.lastScrollTime || time))) / 1000); + scrollElement[scrollAttr] -= moveDistance; + this.lastScrollTime = time; + // 判断是不是到尽头 + if ( + (scrollElement[scrollAttr] === 0 && orientation === DropScrollOrientation.backward) || + (scrollElement[scrollAttr] + + scrollElement.getBoundingClientRect()[rectWidthAttr] - + scrollElement[offsetWidthAttr] + + scrollElement[clientWidthAttr] === + scrollElement[scrollWidthAttr] && + orientation === DropScrollOrientation.forward) + ) { + compareTarget!.style.pointerEvents = 'none'; + this.toggleActiveClass(compareTarget!, false); + } + this.animationFrameId = undefined; + }); + if (this.backSpaceDroppable) { + Utils.dispatchEventToUnderElement(event); + } + }; + } + delegateDropEvent(event: DragEvent) { + if (this.backSpaceDroppable) { + const ev = Utils.dispatchEventToUnderElement(event); + if (ev.defaultPrevented) { + event.preventDefault(); + event.stopPropagation(); + } + } + } + getCriticalEdge(direction: DropScrollDirection, orientation: DropScrollOrientation): DropScrollTriggerEdge { + return ( + (direction === 'v' && orientation === DropScrollOrientation.forward && 'bottom') || + (direction === 'v' && orientation === DropScrollOrientation.backward && 'top') || + (direction !== 'v' && orientation === DropScrollOrientation.forward && 'right') || + (direction !== 'v' && orientation === DropScrollOrientation.backward && 'left') || + 'bottom' + ); + } + getSecondEdge(direction: DropScrollDirection): DropScrollTriggerEdge { + return (direction === 'v' && 'left') || (direction !== 'v' && 'top') || 'left'; + } + + createScrollArea(direction: DropScrollDirection, orientation: DropScrollOrientation) { + const area = this.document.createElement('div'); + area.className = `dropover-scroll-area dropover-scroll-area-${this.getCriticalEdge(direction, orientation)}`; + // 处理大小 + area.classList.add('active'); + this.setAreaSize(area, direction, orientation); + // 处理位置 + area.style.position = 'absolute'; + this.setAreaStyleLayout(area, direction, orientation); + + // 默认不展示 + area.style.display = 'none'; + // 附着元素 + this.el.nativeElement.parentNode.appendChild(area, this.el.nativeElement); + return area; + } + + setAreaSize(area: HTMLElement, direction: DropScrollDirection, orientation: DropScrollOrientation) { + const rect = this.el.nativeElement.getBoundingClientRect(); + const containerAttr = direction === 'v' ? 'height' : 'width'; + const responseEdgeWidth = + typeof this.responseEdgeWidth === 'string' ? this.responseEdgeWidth : this.responseEdgeWidth(rect[containerAttr]); + const settingOffset = + this.viewOffset && (orientation === DropScrollOrientation.forward ? this.viewOffset.forward : this.viewOffset.backward); + let width = direction === 'v' ? rect.width + 'px' : responseEdgeWidth; + let height = direction === 'v' ? responseEdgeWidth : rect.height + 'px'; + if (settingOffset) { + if (settingOffset.widthOffset) { + width = 'calc(' + width + ' + ' + settingOffset.widthOffset + 'px)'; + } + if (settingOffset.heightOffset) { + height = 'calc(' + height + ' + ' + settingOffset.heightOffset + 'px)'; + } + } + area.style.width = width; + area.style.height = height; + } + + setAreaStyleLayout(area: HTMLElement, direction: DropScrollDirection, orientation: DropScrollOrientation) { + const target = this.el.nativeElement; + const relatedTarget = this.el.nativeElement.parentNode; + const defaultOffset = { left: 0, right: 0, top: 0, bottom: 0 }; + const settingOffset = + (this.viewOffset && (orientation === DropScrollOrientation.forward ? this.viewOffset.forward : this.viewOffset.backward)) || + defaultOffset; + + const criticalEdge = this.getCriticalEdge(direction, orientation); + const secondEdge = this.getSecondEdge(direction); + [criticalEdge, secondEdge].forEach((edge) => { + area.style[edge] = this.getRelatedPosition(target, relatedTarget, edge, settingOffset[edge]); + }); + } + + getRelatedPosition(target: HTMLElement, relatedTarget: HTMLElement, edge: DropScrollTriggerEdge, offsetValue?: number) { + if (typeof window === 'undefined') { + return '0px'; + } + const relatedComputedStyle = window.getComputedStyle(relatedTarget) as any; + const relatedRect = relatedTarget.getBoundingClientRect() as any; + const selfRect = target.getBoundingClientRect() as any; + const helper = { + left: ['left', 'Left'], + right: ['right', 'Right'], + top: ['top', 'Top'], + bottom: ['bottom', 'Bottom'], + }; + let factor = 1; + if (edge === 'right' || edge === 'bottom') { + factor = -1; + } + return ( + (selfRect[helper[edge][0]] - + relatedRect[helper[edge][0]] + + parseInt(relatedComputedStyle['border' + helper[edge][1] + 'Width'], 10)) * + factor + + (offsetValue || 0) + + 'px' + ); + } + + resizeArea() { + [ + { area: this.forwardScrollArea!, orientation: DropScrollOrientation.forward }, + { area: this.backwardScrollArea!, orientation: DropScrollOrientation.backward }, + ].forEach((item) => { + this.setAreaSize(item.area, this.direction, item.orientation); + this.setAreaStyleLayout(item.area, this.direction, item.orientation); + }); + } + + toggleScrollToOneEnd(scrollElement: any, toggleElement: HTMLElement, direction: DropScrollDirection, orientation: DropScrollOrientation) { + const scrollAttr = direction === 'v' ? 'scrollTop' : 'scrollLeft'; + const scrollWidthAttr = direction === 'v' ? 'scrollHeight' : 'scrollWidth'; + const offsetWidthAttr = direction === 'v' ? 'offsetHeight' : 'offsetWidth'; + const clientWidthAttr = direction === 'v' ? 'clientHeight' : 'clientWidth'; + const rectWidthAttr = direction === 'v' ? 'height' : 'width'; + if ( + (scrollElement[scrollAttr] === 0 && orientation === DropScrollOrientation.backward) || + (Math.abs( + scrollElement[scrollAttr] + + scrollElement.getBoundingClientRect()[rectWidthAttr] - + scrollElement[scrollWidthAttr] - + scrollElement[offsetWidthAttr] + + scrollElement[clientWidthAttr] + ) < 1 && + orientation === DropScrollOrientation.forward) + ) { + toggleElement.style.pointerEvents = 'none'; + this.toggleActiveClass(toggleElement, false); + } else { + toggleElement.style.pointerEvents = 'auto'; + this.toggleActiveClass(toggleElement, true); + } + } + + cleanLastScrollTime() { + if (this.animationFrameId && typeof window !== 'undefined') { + window.cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = undefined; + } + this.lastScrollTime = undefined; + } + + toggleActiveClass(target: HTMLElement, active: boolean) { + if (active) { + target.classList.remove('inactive'); + target.classList.add('active'); + } else { + target.classList.remove('active'); + target.classList.add('inactive'); + } + } + + allowScroll(): boolean { + if (!this.dropScrollScope) { + return true; + } + let allowed = false; + if (typeof this.dropScrollScope === 'string') { + if (typeof this.dragDropService.scope === 'string') { + allowed = this.dragDropService.scope === this.dropScrollScope; + } + if (this.dragDropService.scope instanceof Array) { + allowed = this.dragDropService.scope.indexOf(this.dropScrollScope) > -1; + } + } + if (this.dropScrollScope instanceof Array) { + if (typeof this.dragDropService.scope === 'string') { + allowed = this.dropScrollScope.indexOf(this.dragDropService.scope) > -1; + } + if (this.dragDropService.scope instanceof Array) { + allowed = + this.dropScrollScope.filter((item) => { + return this.dragDropService.scope!.indexOf(item) !== -1; + }).length > 0; + } + } + return allowed; + } +} + +export default { + mounted( + el: HTMLElement & { [props: string]: any }, + binding: DirectiveBinding, + vNode + ) { + const context = vNode['ctx'].provides; + const dragDropService = injectFromContext(DragDropService.TOKEN, context) as DragDropService; + const droppableDirective = (el[DropScrollEnhancedDirective.INSTANCE_KEY] = new DropScrollEnhancedDirective(el, dragDropService)); + droppableDirective.setInput(binding.value); + droppableDirective.mounted(); + setTimeout(() => { + droppableDirective.ngAfterViewInit?.(); + }, 0); + }, + updated(el: HTMLElement & { [props: string]: any }, binding: DirectiveBinding) { + const droppableDirective = el[DropScrollEnhancedDirective.INSTANCE_KEY] as DropScrollEnhancedDirective; + droppableDirective.updateInput(binding.value, binding.oldValue!); + }, + beforeUnmount(el: HTMLElement & { [props: string]: any }) { + const droppableDirective = el[DropScrollEnhancedDirective.INSTANCE_KEY] as DropScrollEnhancedDirective; + droppableDirective.ngOnDestroy?.(); + }, +}; + +export class DropScrollEnhancedSideDirective extends DropScrollEnhancedDirective { + inputNameMap?: { [key: string]: string } | undefined = { + direction: 'sideDirection', + }; + sideDirection: DropScrollDirection = 'h'; + direction: DropScrollDirection = 'v'; + ngOnInit() { + this.direction = this.sideDirection === 'v' ? 'h' : 'v'; + } +} +export const DropScrollEnhancedSide = { + mounted( + el: HTMLElement & { [props: string]: any }, + binding: DirectiveBinding, + vNode + ) { + const context = vNode['ctx'].provides; + const dragDropService = injectFromContext(DragDropService.TOKEN, context) as DragDropService; + const droppableDirective = (el[DropScrollEnhancedSideDirective.INSTANCE_KEY] = new DropScrollEnhancedSideDirective( + el, + dragDropService + )); + droppableDirective.setInput(binding.value); + droppableDirective.mounted(); + setTimeout(() => { + droppableDirective.ngAfterViewInit?.(); + }, 0); + }, + updated(el: HTMLElement & { [props: string]: any }, binding: DirectiveBinding) { + const droppableDirective = el[DropScrollEnhancedSideDirective.INSTANCE_KEY] as DropScrollEnhancedSideDirective; + droppableDirective.updateInput(binding.value, binding.oldValue!); + }, + beforeUnmount(el: HTMLElement & { [props: string]: any }) { + const droppableDirective = el[DropScrollEnhancedSideDirective.INSTANCE_KEY] as DropScrollEnhancedSideDirective; + droppableDirective.ngOnDestroy?.(); + }, +}; diff --git a/packages/devui-vue/devui/dragdrop-new/src/droppable.directive.ts b/packages/devui-vue/devui/dragdrop-new/src/droppable.directive.ts new file mode 100644 index 0000000000..936babfbb0 --- /dev/null +++ b/packages/devui-vue/devui/dragdrop-new/src/droppable.directive.ts @@ -0,0 +1,839 @@ +import { Subject, Subscription, distinctUntilChanged, filter, fromEvent } from 'rxjs'; +import { EventEmitter } from './preserve-next-event-emitter'; +import { DragDropService } from './drag-drop.service'; +import { Utils, injectFromContext, provideToContext } from './utils'; +import { DirectiveBinding, InjectionKey, VNode } from 'vue'; +import { NgDirectiveBase } from './directive-base'; +import { DraggableDirective } from './draggable.directive'; + +export type DropIndexFlag = 'beforeAll' | 'afterAll'; +export interface IDroppableBinding { + dragOverClass?: string; + dropScope?: string | Array; + placeholderTag?: string; + placeholderStyle?: Record; + placeholderText?: string; + allowDropOnItem?: boolean; + dragOverItemClass?: string; + nestingTargetRect?: { + height?: number; + width?: number; + }; + switchWhileCrossEdge?: boolean; + defaultDropPosition?: 'closest' | 'before' | 'after'; + dropSortCountSelector: string; + dropSortVirtualScrollOption?: { + totalLength?: number; + startIndex?: number; + // innerSortContainer?: HTMLElement | string; // 用于虚拟滚动列表结构发生内嵌 + }; +} +export interface IDroppableListener { + '@dragEnterEvent'?: (_: any) => void; + '@dragOverEvent'?: (_: any) => void; + '@dragLeaveEvent'?: (_: any) => void; + '@dropEvent'?: (_: any) => void; +} +export class DropEvent { + nativeEvent: any; + dragData: any; + batchDragData: any; + dropSubject: Subject; + dropIndex?: number; + dragFromIndex?: number; + dropOnItem?: boolean; + dropOnOrigin?: boolean; + constructor( + event: any, + data: any, + dropSubject: Subject, + dropIndex?: number, + dragFromIndex?: number, + dropOnItem?: boolean, + dropOnOrigin?: boolean, + batchDragData?: Array + ) { + this.nativeEvent = event; + this.dragData = data; + this.dropSubject = dropSubject; + this.dropIndex = dropIndex; + this.dragFromIndex = dragFromIndex; + this.dropOnItem = dropOnItem; + this.dropOnOrigin = dropOnOrigin; + this.batchDragData = batchDragData; + } +} +export interface DragPlaceholderInsertionEvent { + command: 'insertBefore' | 'append' | 'remove'; + container?: HTMLElement; + relatedEl?: Element; +} +export interface DragPlaceholderInsertionIndexEvent { + command: 'insertBefore' | 'append' | 'remove'; + index?: number; +} +export class DroppableDirective extends NgDirectiveBase { + static INSTANCE_KEY = '__vueDevuiDroppableDirectiveInstance'; + static TOKEN: InjectionKey = Symbol('DROPPABLE_DIRECTIVE_TOKEN'); + hostListenerMap?: { [key: string]: string } | undefined = { + drop: 'drop', + }; + dragEnterEvent: EventEmitter = new EventEmitter(); + dragOverEvent: EventEmitter = new EventEmitter(); + dragLeaveEvent: EventEmitter = new EventEmitter(); + dropEvent: EventEmitter = new EventEmitter(); // 注意使用了虚拟滚动后,DropEvent中的dragFromIndex无效 + + dragOverClass?: string; + dropScope: string | Array = 'default'; + placeholderTag = 'div'; + placeholderStyle: any = { backgroundColor: ['#859bff', `var(--devui-brand-foil, #859bff)`], opacity: '.4' }; + placeholderText = ''; + allowDropOnItem = false; + dragOverItemClass?: string; + nestingTargetRect?: { height?: number; width?: number }; + switchWhileCrossEdge = false; + + defaultDropPosition: 'closest' | 'before' | 'after' = 'closest'; + dropSortCountSelector?: string; + dropSortVirtualScrollOption?: { + totalLength?: number; + startIndex?: number; + // innerSortContainer?: HTMLElement | string; // 用于虚拟滚动列表结构发生内嵌 + }; + private dropFlag?: DropIndexFlag; + + private sortContainer: any; + private sortDirection?: 'v' | 'h'; + private sortDirectionZMode?: boolean; + private placeholder: any; + + // 用于修复dragleave多次触发 + private dragCount = 0; + + private dropIndex?: number = undefined; + + private dragStartSubscription?: Subscription; + private dragEndSubscription?: Subscription; + private dropEndSubscription?: Subscription; + + // 记录上一次悬停的元素,用于对比悬停的元素等是否发生变化 + private overElement?: any; + + private dragPartEventSub?: Subscription; + private allowDropCache?: boolean; + private dragElIndex?: number; + /* 协同拖拽需要 */ + placeholderInsertionEvent = new Subject(); + placeholderRenderEvent = new Subject(); + document: Document; + el: { nativeElement: any } = { nativeElement: null }; + private dragDropService: DragDropService; + + constructor(el: HTMLElement, dragDropService: DragDropService) { + super(); + this.document = window.document; + this.el.nativeElement = el; + this.dragDropService = dragDropService; + } + + ngOnInit() { + this.placeholder = this.document.createElement(this.placeholderTag); + this.placeholder.className = 'drag-placeholder'; + this.placeholder.innerText = this.placeholderText; + this.dragStartSubscription = this.dragDropService.dragStartEvent.subscribe(() => this.setPlaceholder()); + if (this.dragDropService.draggedEl) { + this.setPlaceholder(); // 虚拟滚动生成元素过程中 + } + this.dropEndSubscription = this.dragDropService.dropEvent.subscribe(() => { + if (this.dragDropService.draggedEl) { + if (!this.dragDropService.dragFollow) { + Utils.addElStyles(this.dragDropService.draggedEl, { display: '' }); + this.dragDropService.dragElShowHideEvent.next(true); + } + } + this.removePlaceholder(); + this.overElement = undefined; + this.allowDropCache = undefined; + this.dragElIndex = undefined; + this.dropIndex = undefined; + }); + this.dragEndSubscription = this.dragDropService.dragEndEvent.subscribe(() => { + if (this.dragDropService.draggedEl) { + if (!this.dragDropService.dragFollow) { + Utils.addElStyles(this.dragDropService.draggedEl, { display: '' }); + this.dragDropService.dragElShowHideEvent.next(true); + } + } + this.removePlaceholder(); + this.dragCount = 0; + this.overElement = undefined; + this.allowDropCache = undefined; + this.dragElIndex = undefined; + this.dropIndex = undefined; + }); + + this.dragPartEventSub = new Subscription(); + this.dragPartEventSub.add( + fromEvent(this.el.nativeElement, 'dragover') + .pipe( + filter((event) => this.allowDrop(event)), + distinctUntilChanged((prev, current) => { + const bool = prev.clientX === current.clientX && prev.clientY === current.clientY && prev.target === current.target; + if (bool) { + current.preventDefault(); + current.stopPropagation(); + } + return bool; + }) + ) + .subscribe((event) => this.dragOver(event)) + ); + this.dragPartEventSub.add(fromEvent(this.el.nativeElement, 'dragenter').subscribe((event) => this.dragEnter(event))); + this.dragPartEventSub.add(fromEvent(this.el.nativeElement, 'dragleave').subscribe((event) => this.dragLeave(event))); + } + + ngAfterViewInit() { + if (this.el.nativeElement.hasAttribute('d-sortable')) { + this.sortContainer = this.el.nativeElement; + } else { + this.sortContainer = this.el.nativeElement.querySelector('[d-sortable]'); + } + this.sortDirection = this.sortContainer ? this.sortContainer.getAttribute('dsortable') || 'v' : 'v'; + this.sortDirectionZMode = this.sortContainer ? this.sortContainer.getAttribute('d-sortable-zmode') === 'true' || false : false; + } + + ngOnDestroy() { + this.dragStartSubscription?.unsubscribe(); + this.dragEndSubscription?.unsubscribe(); + this.dropEndSubscription?.unsubscribe(); + if (this.dragPartEventSub) { + this.dragPartEventSub.unsubscribe(); + } + } + + dragEnter(e: DragEvent) { + this.dragCount++; + e.preventDefault(); // ie11 dragenter需要preventDefault否则dragover无效 + this.dragEnterEvent.emit(e); + } + + dragOver(e: DragEvent) { + if (this.allowDrop(e)) { + if (this.dragDropService.dropTargets.indexOf(this.el) === -1) { + this.dragDropService.dropTargets.forEach((el) => { + const placeHolderEl = el!.nativeElement.querySelector('.drag-placeholder'); + if (placeHolderEl) { + placeHolderEl.parentElement.removeChild(placeHolderEl); + } + Utils.removeClass(el, this.dragOverClass!); + this.removeDragoverItemClass(el.nativeElement); + }); + this.dragDropService.dropTargets = [this.el]; + this.overElement = undefined; // 否则会遇到上一次position= 这一次的然后不刷新和插入。 + } + Utils.addClass(this.el, this.dragOverClass!); + const hitPlaceholder = this.dragDropService.dragOriginPlaceholder && this.dragDropService.dragOriginPlaceholder.contains(e.target); + if ( + this.sortContainer && + ((hitPlaceholder && this.overElement === undefined) || + !((e.target as HTMLElement).contains(this.placeholder) || hitPlaceholder) || + (this.switchWhileCrossEdge && !this.placeholder.contains(e.target) && !hitPlaceholder) || // 越边交换回折的情况需要重新计算 + (!this.sortContainer.contains(e.target) && this.defaultDropPosition === 'closest')) // 就近模式需要重新计算 + ) { + const overElement = this.findSortableEl(e); + if ( + !(this.overElement && overElement) || + this.overElement.index !== overElement.index || + (this.allowDropOnItem && + this.overElement.position !== overElement.position && + (this.overElement.position === 'inside' || overElement.position === 'inside')) + ) { + // overElement的参数有刷新的时候才进行插入等操作 + this.overElement = overElement; + + this.insertPlaceholder(overElement); + + this.removeDragoverItemClass(this.sortContainer, overElement); + if (overElement.position === 'inside' && this.dragOverItemClass) { + Utils.addClass(overElement.el, this.dragOverItemClass); + } + } else { + this.overElement = overElement; + } + } else { + if (this.sortContainer && this.overElement && this.overElement.el) { + if (!this.overElement.el.contains(e.target)) { + this.overElement.realEl = e.target; + } else { + this.overElement.realEl = undefined; + } + } + } + if (this.dragDropService.draggedEl) { + if (!this.dragDropService.dragFollow) { + Utils.addElStyles(this.dragDropService.draggedEl, { display: 'none' }); + this.dragDropService.dragElShowHideEvent.next(false); + if (this.dragDropService.dragOriginPlaceholder) { + Utils.addElStyles(this.dragDropService.dragOriginPlaceholder, { display: 'block' }); + } + } + } + e.preventDefault(); + e.stopPropagation(); + this.dragOverEvent.emit(e); + } + } + + dragLeave(e: DragEvent) { + // 用于修复包含子元素时,多次触发dragleave + this.dragCount--; + + if (this.dragCount === 0) { + if (this.dragDropService.dropTargets.indexOf(this.el) !== -1) { + this.dragDropService.dropTargets = []; + } + Utils.removeClass(this.el, this.dragOverClass!); + this.removePlaceholder(); + this.removeDragoverItemClass(this.el.nativeElement); + this.overElement = undefined; + this.dragElIndex = undefined; + this.dropIndex = undefined; + } + e.preventDefault(); + this.dragLeaveEvent.emit(e); + } + + // @HostListener('drop', ['$event']) + drop(e: DragEvent) { + if (!this.allowDrop(e)) { + return; + } + this.dragCount = 0; + Utils.removeClass(this.el, this.dragOverClass!); + this.removeDragoverItemClass(this.sortContainer); + this.removePlaceholder(); + e.preventDefault(); + e.stopPropagation(); + this.dragDropService.dropOnOrigin = this.isDragPlaceholderPosition(this.dropIndex!); + const draggedElIdentity = this.dragDropService.draggedElIdentity; + this.dragDropService.draggedElIdentity = undefined; // 需要提前清除,避免新生成的节点复用了id 刷新了dragOriginPlaceholder + let batchDraggble: Array = []; + if (this.dragDropService.batchDragData && this.dragDropService.batchDragData.length > 1) { + batchDraggble = this.dragDropService.batchDragData + .map((dragData) => dragData.draggable) + .filter((draggable) => draggable && draggable.el.nativeElement !== this.dragDropService.draggedEl); + } + this.dropEvent.emit( + new DropEvent( + e, + this.dragDropService.dragData, + this.dragDropService.dropEvent, + this.dropSortVirtualScrollOption ? this.getRealIndex(this.dropIndex!, this.dropFlag) : this.dropIndex, + this.sortContainer ? this.checkSelfFromIndex(this.dragDropService.draggedEl) : -1, + this.dragDropService.dropOnItem, + this.dragDropService.dropOnOrigin, + this.dragDropService.batchDragging ? this.dragDropService.getBatchDragData(draggedElIdentity) : undefined + ) + ); + // 如果drop之后drag元素被删除,则不会发生dragend事件,需要代替dragend清理 + if (this.dragDropService.dragFollow) { + this.dragDropService.disableDraggedCloneNodeFollowMouse(); + } else { + Utils.addElStyles(this.dragDropService.draggedEl, { display: undefined }); + this.dragDropService.dragElShowHideEvent.next(false); + } + if (batchDraggble.length > 0 && this.dragDropService.batchDragging) { + batchDraggble.forEach((draggable) => { + if (!draggable.originPlaceholder || draggable.originPlaceholder.show === false) { + draggable.el.nativeElement.style.display = ''; + } else if (draggable.originPlaceholder.removeDelay! > 0 && !this.dragDropService.dropOnOrigin) { + draggable.delayRemoveOriginPlaceholder(false); + } else { + draggable.el.nativeElement.style.display = ''; + draggable.removeOriginPlaceholder(false); + } + }); + } + this.dragDropService.dropEvent.next(e); + this.dragDropService.dragData = undefined; + this.dragDropService.scope = undefined; + this.dragDropService.draggedEl = undefined; + this.dragDropService.dragFollow = undefined; + this.dragDropService.dragFollowOptions = undefined; + this.dragDropService.dragOffset = undefined; + this.dragDropService.dropOnOrigin = undefined; + this.dragDropService.batchDragging = false; + this.dragDropService.batchDragStyle = undefined; + this.dragDropService.dragPreviewDirective = undefined; + } + + allowDrop(e: DragEvent): boolean { + if (!e) { + return false; + } + if (this.allowDropCache !== undefined) { + return this.allowDropCache; + } + let allowed = false; + if (typeof this.dropScope === 'string') { + if (typeof this.dragDropService.scope === 'string') { + allowed = this.dragDropService.scope === this.dropScope; + } + if (this.dragDropService.scope instanceof Array) { + allowed = this.dragDropService.scope.indexOf(this.dropScope) > -1; + } + } + if (this.dropScope instanceof Array) { + if (typeof this.dragDropService.scope === 'string') { + allowed = this.dropScope.indexOf(this.dragDropService.scope) > -1; + } + if (this.dragDropService.scope instanceof Array) { + allowed = + this.dropScope.filter((item) => { + return this.dragDropService.scope!.indexOf(item) !== -1; + }).length > 0; + } + } + this.allowDropCache = allowed; + return allowed; + } + + private dropSortCountSelectorFilterFn = (value: HTMLElement) => { + return ( + Utils.matches(value, this.dropSortCountSelector!) || + value.contains(this.placeholder) || + value === this.dragDropService.dragOriginPlaceholder + ); + }; + + // 查询需要插入placeholder的位置 + /* eslint-disable-next-line complexity*/ + private findSortableEl(event: DragEvent) { + const moveElement = event.target; + let overElement: any = null; + if (!this.sortContainer) { + return overElement; + } + overElement = { index: 0, el: null, position: 'before' }; + this.dropIndex = 0; + this.dropFlag = undefined; + let childEls: Array = Utils.slice(this.sortContainer.children); + // 删除虚拟滚动等的额外元素不需要计算的元素 + if (this.dropSortCountSelector) { + childEls = childEls.filter(this.dropSortCountSelectorFilterFn); + } + // 如果没有主动删除则删除多余的originplaceholder + if (childEls.some((el) => el !== this.dragDropService.dragOriginPlaceholder && el.classList.contains('drag-origin-placeholder'))) { + childEls = childEls.filter( + (el) => !(el.classList.contains('drag-origin-placeholder') && el !== this.dragDropService.dragOriginPlaceholder) + ); + } + // 要先删除clonenode否则placeholderindex是错的 + if (this.dragDropService.dragFollow && this.dragDropService.dragCloneNode) { + const cloneNodeIndex = childEls.findIndex((value) => value === this.dragDropService.dragCloneNode); + if (-1 !== cloneNodeIndex) { + childEls.splice(cloneNodeIndex, 1); + } + } + // 计算index数组需要删除源占位符 + if (this.dragDropService.dragOriginPlaceholder) { + const dragOriginPlaceholderIndex = childEls.findIndex((value) => value === this.dragDropService.dragOriginPlaceholder); + if (-1 !== dragOriginPlaceholderIndex) { + this.dragElIndex = dragOriginPlaceholderIndex - 1; + childEls.splice(dragOriginPlaceholderIndex, 1); + } else { + this.dragElIndex = -1; + } + } else { + this.dragElIndex = -1; + } + // 查询是否已经插入了placeholder + const placeholderIndex = childEls.findIndex((value) => value.contains(this.placeholder)); + // 删除placeholder + if (-1 !== placeholderIndex) { + childEls.splice(placeholderIndex, 1); + } + // 如果还有placeholder在前面 dragElIndex得再减一 + if (-1 !== placeholderIndex && -1 !== this.dragElIndex && placeholderIndex < this.dragElIndex) { + this.dragElIndex--; + } + const positionIndex = -1 !== placeholderIndex ? placeholderIndex : this.dragElIndex; + const currentIndex = childEls.findIndex( + (value) => + value.contains(moveElement as Node) || + (value.nextElementSibling === moveElement && value.nextElementSibling!.classList.contains('drag-origin-placeholder')) + ); + if (this.switchWhileCrossEdge && !this.allowDropOnItem && childEls.length && -1 !== positionIndex && currentIndex > -1) { + // 越过元素边界立即交换位置算法 + const lastIndex = positionIndex; + // 解决抖动 + const realEl = this.overElement && (this.overElement.realEl || this.overElement.el); + if (-1 !== currentIndex && realEl === childEls[currentIndex]) { + this.dropIndex = this.overElement.index; + return this.overElement; + } + + overElement = { + index: lastIndex > currentIndex ? currentIndex : currentIndex + 1, + el: childEls[currentIndex], + position: lastIndex > currentIndex ? 'before' : 'after', + }; + + this.dragDropService.dropOnItem = false; + this.dropIndex = overElement.index; + return overElement; + } + + if ( + moveElement === this.sortContainer || + (moveElement as HTMLElement).classList?.contains('drag-origin-placeholder') || + moveElement === (this.dragDropService && this.dragDropService.dragOriginPlaceholder) || + (!this.sortContainer.contains(moveElement) && this.defaultDropPosition === 'closest') + ) { + if (!childEls.length) { + this.dropIndex = 0; + this.dragDropService.dropOnItem = false; + return overElement; + } + // 落入A元素和B元素的间隙里 + let findInGap = false; + for (let i = 0; i < childEls.length; i++) { + const targetElement = childEls[i]; + // 处理非越边的落到side-origin-placeholder + if (childEls[i].nextSibling === moveElement && (moveElement as HTMLElement).classList.contains('drag-origin-placeholder')) { + const position = this.calcPosition(event, moveElement); + this.dragDropService.dropOnItem = position === 'inside'; + overElement = { index: position === 'after' ? i + 1 : i, el: childEls[i], position: position }; + this.dropIndex = overElement.index; + return overElement; + } + const positionOutside = this.calcPositionOutside(event, targetElement); + if (positionOutside === 'before') { + this.dragDropService.dropOnItem = false; + overElement = { index: i, el: childEls[i], position: positionOutside, realEl: moveElement }; + this.dropIndex = overElement.index; + findInGap = true; + break; + } else { + // for 'notsure' + } + } + if (!findInGap) { + this.dragDropService.dropOnItem = false; + overElement = { index: childEls.length, el: childEls[childEls.length - 1], position: 'after', realEl: moveElement }; + this.dropIndex = childEls.length; + } + return overElement; + } + if (!this.sortContainer.contains(moveElement)) { + if (this.defaultDropPosition === 'before') { + overElement = { index: 0, el: childEls.length ? childEls[0] : null, position: 'before', realEl: moveElement }; + this.dropFlag = 'beforeAll'; + } else { + overElement = { + index: childEls.length, + el: childEls.length ? childEls[childEls.length - 1] : null, + position: 'after', + realEl: moveElement, + }; + this.dropFlag = 'afterAll'; + } + this.dropIndex = overElement.index; + return overElement; + } + let find = false; + for (let i = 0; i < childEls.length; i++) { + if (childEls[i].contains(moveElement as HTMLElement)) { + const targetElement = childEls[i]; + const position = this.calcPosition(event, targetElement); + this.dragDropService.dropOnItem = position === 'inside'; + overElement = { index: position === 'after' ? i + 1 : i, el: childEls[i], position: position }; + this.dropIndex = overElement.index; + find = true; + break; + } + } + if (!find) { + if (childEls.length) { + overElement = { index: childEls.length, el: childEls[childEls.length - 1], position: 'after' }; + } + this.dropIndex = childEls.length; + this.dragDropService.dropOnItem = false; + } + return overElement; + } + + private calcPosition(event: any, targetElement: any) { + const rect = targetElement.getBoundingClientRect(); + const relY = event.clientY - (rect.y || rect.top); + const relX = event.clientX - (rect.x || rect.left); + + // 处理允许drop到元素自己 + if (this.allowDropOnItem) { + const dropOnItemEdge = { + // 有内嵌列表的时候需要修正元素的高度活宽度 + height: (this.nestingTargetRect && this.nestingTargetRect.height) || rect.height, + width: (this.nestingTargetRect && this.nestingTargetRect.width) || rect.width, + }; + const threeQuartersOfHeight = (dropOnItemEdge.height * 3) / 4; + const threeQuartersOfWidth = (dropOnItemEdge.width * 3) / 4; + const AQuarterOfHeight = Number(dropOnItemEdge.height) / 4; + const AQuarterOfWidth = Number(dropOnItemEdge.width) / 4; + + if (this.sortDirectionZMode) { + const slashPosition = relY / dropOnItemEdge.height + relX / dropOnItemEdge.width; + if (slashPosition > 0.3 && slashPosition <= 0.7) { + return 'inside'; + } else if (slashPosition > 0.7) { + const slashPositionNesting = + (relY - rect.height + dropOnItemEdge.height) / dropOnItemEdge.height + + (relX - rect.width + dropOnItemEdge.width) / dropOnItemEdge.width; + if (slashPositionNesting <= 0.7) { + return 'inside'; + } + } + } + if ( + (this.sortDirection === 'v' && relY > AQuarterOfHeight && relY <= threeQuartersOfHeight) || + (this.sortDirection !== 'v' && relX > AQuarterOfWidth && relX <= threeQuartersOfWidth) + ) { + // 高度的中间1/4 - 3/4 属于drop到元素自己 + return 'inside'; + } else if ( + (this.sortDirection === 'v' && relY > threeQuartersOfHeight && relY <= rect.height - AQuarterOfHeight) || + (this.sortDirection !== 'v' && relX > threeQuartersOfWidth && relX <= rect.width - AQuarterOfWidth) + ) { + // 内嵌列表后中间区域都属于inside + return 'inside'; + } + } + + if (this.sortDirectionZMode) { + if (relY / rect.height + relX / rect.width < 1) { + return 'before'; + } + return 'after'; + } + // 其他情况保持原来的属于上半部分或者下半部分 + if ((this.sortDirection === 'v' && relY > rect.height / 2) || (this.sortDirection !== 'v' && relX > rect.width / 2)) { + return 'after'; + } + return 'before'; + } + + private calcPositionOutside(event: any, targetElement: any) { + // targetElement 获取 getBoundingClientRect + const rect = this.getBoundingRectAndRealPosition(targetElement); + const relY = event.clientY - (rect.y || rect.top); + const relX = event.clientX - (rect.x || rect.left); + + if (this.sortDirectionZMode) { + if ( + (this.sortDirection === 'v' && (relY < 0 || (relY < rect.height && relX < 0))) || + (this.sortDirection !== 'v' && (relX < 0 || (relX < rect.width && relY < 0))) + ) { + return 'before'; + } + return 'notsure'; + } + + if ((this.sortDirection === 'v' && relY < rect.height / 2) || (this.sortDirection !== 'v' && relX < rect.width / 2)) { + return 'before'; + } + return 'notsure'; + } + setPlaceholder = () => { + this.placeholder.style.width = this.dragDropService.dragOffset!.width + 'px'; + this.placeholder.style.height = this.dragDropService.dragOffset!.height + 'px'; // ie下clientHeight为0 + Utils.addElStyles(this.placeholder, this.placeholderStyle); + this.placeholderRenderEvent.next({ width: this.dragDropService.dragOffset!.width, height: this.dragDropService.dragOffset!.height }); + }; + + // 插入placeholder + private insertPlaceholder(overElement: any) { + const tempScrollTop = this.sortContainer.scrollTop; + const tempScrollLeft = this.sortContainer.scrollLeft; + let hitPlaceholder = false; + let cmd: DragPlaceholderInsertionIndexEvent; + const getIndex = (arr: Array, el: HTMLElement, defaultValue: number) => { + const index = arr.indexOf(el); + return index > -1 ? index : defaultValue; + }; + if (null !== overElement) { + const sortContainerChildren = Utils.slice(this.sortContainer.children).filter( + (el) => el !== this.dragDropService.dragCloneNode + ); + + if (overElement.el === null) { + cmd = { + command: 'append', + }; + this.sortContainer.appendChild(this.placeholder); + } else { + if (overElement.position === 'inside') { + cmd = { + command: 'remove', + }; + this.removePlaceholder(); + } else if (this.dragDropService.dragOriginPlaceholder && this.isDragPlaceholderPosition(overElement.index)) { + cmd = { + command: 'remove', + }; + this.removePlaceholder(); + hitPlaceholder = true; + } else if (overElement.position === 'after') { + if ( + overElement.el.nextSibling && + overElement.el.nextSibling.classList && + overElement.el.nextSibling.classList.contains('drag-origin-placeholder') + ) { + // 针对多源占位符场景 + cmd = { + command: 'insertBefore', + index: getIndex(sortContainerChildren, overElement.el.nextSibling, sortContainerChildren.length) + 1, + }; + this.sortContainer.insertBefore(this.placeholder, overElement.el.nextSibling.nextSibling); + } else { + cmd = { + command: 'insertBefore', + index: getIndex(sortContainerChildren, overElement.el, sortContainerChildren.length) + 1, + }; + this.sortContainer.insertBefore(this.placeholder, overElement.el.nextSibling); + } + } else { + cmd = { + command: 'insertBefore', + index: getIndex(sortContainerChildren, overElement.el, sortContainerChildren.length), + }; + this.sortContainer.insertBefore(this.placeholder, overElement.el); + } + } + } + this.placeholderInsertionEvent.next(cmd!); + this.sortContainer.scrollTop = tempScrollTop; + this.sortContainer.scrollLeft = tempScrollLeft; + if (this.dragDropService.dragOriginPlaceholder) { + if (hitPlaceholder) { + this.hitDragOriginPlaceholder(); + } else { + this.hitDragOriginPlaceholder(false); + } + } + } + + private isDragPlaceholderPosition(index: number) { + if (this.dragElIndex! > -1 && (index === this.dragElIndex || index === this.dragElIndex! + 1)) { + return true; + } else { + return false; + } + } + private hitDragOriginPlaceholder(bool = true) { + const placeholder = this.dragDropService.dragOriginPlaceholder; + if (bool) { + placeholder.classList.add('hit-origin-placeholder'); + } else { + placeholder.classList.remove('hit-origin-placeholder'); + } + } + + private removePlaceholder() { + if (this.sortContainer && this.sortContainer.contains(this.placeholder)) { + this.sortContainer.removeChild(this.placeholder); + this.placeholderInsertionEvent.next({ + command: 'remove', + }); + } + } + + private removeDragoverItemClass(container: HTMLElement, overElement?: any) { + if (this.dragOverItemClass) { + const dragOverItemClassGroup = container.querySelectorAll('.' + this.dragOverItemClass); + if (dragOverItemClassGroup && dragOverItemClassGroup.length > 0) { + for (const element of dragOverItemClassGroup as unknown as Set) { + if (overElement) { + if (element !== overElement.el || overElement.position !== 'inside') { + Utils.removeClass(element, this.dragOverItemClass); + } + } else { + Utils.removeClass(element, this.dragOverItemClass); + } + } + } + } + } + + private checkSelfFromIndex(el: any) { + let fromIndex = -1; + if (!this.sortContainer.contains(el)) { + return fromIndex; + } + let children: Array = Utils.slice(this.sortContainer.children); + if (this.dropSortCountSelector) { + children = children.filter(this.dropSortCountSelectorFilterFn); + } + for (let i = 0; i < children.length; i++) { + if (children[i].contains(this.dragDropService.draggedEl)) { + fromIndex = i; + break; + } + } + return this.getRealIndex(fromIndex); + } + private getRealIndex(index: number, flag?: DropIndexFlag): number { + let realIndex; + const startIndex = (this.dropSortVirtualScrollOption && this.dropSortVirtualScrollOption.startIndex) || 0; + const totalLength = this.dropSortVirtualScrollOption && this.dropSortVirtualScrollOption.totalLength; + if (flag === 'beforeAll') { + realIndex = 0; + } else if (flag === 'afterAll') { + realIndex = totalLength || index; + } else { + realIndex = startIndex + index; + } + return realIndex; + } + + getBoundingRectAndRealPosition(targetElement: HTMLElement) { + // 用于修复部分display none的元素获取到的top和left是0, 取它下一个元素的左上角为坐标 + let rect: any = targetElement.getBoundingClientRect(); + const { bottom, right, width, height } = rect; + if ( + rect.width === 0 && + rect.height === 0 && + (targetElement.style.display === 'none' || getComputedStyle(targetElement).display === 'none') + ) { + if (targetElement.nextElementSibling) { + const { top: realTop, left: realLeft } = targetElement.nextElementSibling.getBoundingClientRect(); + rect = { x: realLeft, y: realTop, top: realTop, left: realLeft, bottom, right, width, height }; + } + } + return rect; + } + getSortContainer() { + return this.sortContainer; + } +} + +export default { + mounted(el: HTMLElement & { [props: string]: any }, binding: DirectiveBinding, vNode: VNode) { + const context = vNode['ctx'].provides; + const dragDropService = injectFromContext(DragDropService.TOKEN, context) as DragDropService; + const droppableDirective = (el[DroppableDirective.INSTANCE_KEY] = new DroppableDirective(el, dragDropService)); + provideToContext(DroppableDirective.TOKEN, droppableDirective, context); + droppableDirective.setInput(binding.value); + droppableDirective.mounted(); + droppableDirective.ngOnInit?.(); + setTimeout(() => { + droppableDirective.ngAfterViewInit?.(); + }, 0); + }, + updated(el: HTMLElement & { [props: string]: any }, binding: DirectiveBinding) { + const droppableDirective = el[DroppableDirective.INSTANCE_KEY] as DroppableDirective; + droppableDirective.updateInput(binding.value, binding.oldValue!); + }, + beforeUnmount(el: HTMLElement & { [props: string]: any }) { + const droppableDirective = el[DroppableDirective.INSTANCE_KEY] as DroppableDirective; + droppableDirective.ngOnDestroy?.(); + }, +}; diff --git a/packages/devui-vue/devui/dragdrop-new/src/preserve-next-event-emitter.ts b/packages/devui-vue/devui/dragdrop-new/src/preserve-next-event-emitter.ts new file mode 100644 index 0000000000..e494eedbee --- /dev/null +++ b/packages/devui-vue/devui/dragdrop-new/src/preserve-next-event-emitter.ts @@ -0,0 +1,137 @@ +import { Subject, Subscription } from 'rxjs'; + +export class EventEmitter extends Subject { + protected __isAsync: boolean; // tslint:disable-line + + constructor(isAsync: boolean = false) { + super(); + this.__isAsync = isAsync; + } + + emit(value?: any) { + super.next(value); + } + + subscribe(generatorOrNext?: any, error?: any, complete?: any): Subscription { + let schedulerFn: (t: any) => any; + let errorFn = (err: any): any => null; + let completeFn = (): any => null; + + if (generatorOrNext && typeof generatorOrNext === 'object') { + schedulerFn = this.__isAsync + ? (value: any) => { + setTimeout(() => generatorOrNext.next(value)); + } + : (value: any) => { + generatorOrNext.next(value); + }; + + if (generatorOrNext.error) { + errorFn = this.__isAsync + ? (err) => { + setTimeout(() => generatorOrNext.error(err)); + } + : (err) => { + generatorOrNext.error(err); + }; + } + + if (generatorOrNext.complete) { + errorFn = this.__isAsync + ? () => { + setTimeout(() => generatorOrNext.complete()); + } + : () => { + generatorOrNext.complete(); + }; + } + } else { + schedulerFn = this.__isAsync + ? (value: any) => { + setTimeout(() => generatorOrNext(value)); + } + : (value: any) => { + generatorOrNext(value); + }; + + if (error) { + errorFn = this.__isAsync + ? (err) => { + setTimeout(() => error(err)); + } + : (err) => { + error(err); + }; + } + + if (complete) { + completeFn = this.__isAsync + ? () => { + setTimeout(() => complete()); + } + : () => { + complete(); + }; + } + } + + const sink = super.subscribe(schedulerFn, errorFn, completeFn); + + if (generatorOrNext instanceof Subscription) { + generatorOrNext.add(sink); + } + + return sink; + } +} + +export class PreserveNextEventEmitter extends EventEmitter { + // 保留注册的 generatorOrNext 构成的函数 + private _schedulerFns: Set | undefined; + private _isAsync: boolean = false; + get schedulerFns() { + return this._schedulerFns; + } + forceCallback(value: T, once = false) { + if (this.schedulerFns && this.schedulerFns.size) { + this.schedulerFns.forEach((fn) => { + fn(value); + }); + if (once) { + this.cleanCallbackFn(); + } + } + } + + cleanCallbackFn() { + this._schedulerFns = undefined; + } + + subscribe(generatorOrNext?: any, error?: any, complete?: any): any { + let schedulerFn: (t: any) => any; + + if (generatorOrNext && typeof generatorOrNext === 'object') { + schedulerFn = this._isAsync + ? (value: any) => { + setTimeout(() => generatorOrNext.next(value)); + } + : (value: any) => { + generatorOrNext.next(value); + }; + } else { + schedulerFn = this._isAsync + ? (value: any) => { + setTimeout(() => generatorOrNext(value)); + } + : (value: any) => { + generatorOrNext(value); + }; + } + if (!this._schedulerFns) { + this._schedulerFns = new Set(); + } + this._schedulerFns.add(schedulerFn); + + return super.subscribe(generatorOrNext, error, complete); + } +} diff --git a/packages/devui-vue/devui/dragdrop-new/src/sortable.directive.ts b/packages/devui-vue/devui/dragdrop-new/src/sortable.directive.ts new file mode 100644 index 0000000000..67deefc5ce --- /dev/null +++ b/packages/devui-vue/devui/dragdrop-new/src/sortable.directive.ts @@ -0,0 +1,41 @@ +import { DirectiveBinding } from 'vue'; +import { NgDirectiveBase } from './directive-base'; + +export interface ISortableBinding { + dSortableZMode?: any; + dSortable?: 'v' | 'h'; + [props: string]: any; +} + +export class SortableDirective extends NgDirectiveBase { + static INSTANCE_KEY = '__vueDevuiSortableDirectiveInstance'; + dSortDirection = 'v'; + dSortableZMode = false; + dSortable = true; + + hostBindingMap?: { [key: string]: string } | undefined = { + dSortDirection: 'dsortable', + dSortableZMode: 'd-sortable-zmode', + dSortable: 'd-sortable', + }; + inputNameMap?: { [key: string]: string } | undefined = { + dSortable: 'dSortDirection', + }; + el: { nativeElement: any } = { nativeElement: null }; + constructor(el: HTMLElement) { + super(); + this.el.nativeElement = el; + } +} + +export default { + mounted(el: HTMLElement & { [props: string]: any }, binding: DirectiveBinding) { + const sortableDirective = (el[SortableDirective.INSTANCE_KEY] = new SortableDirective(el)); + sortableDirective.setInput(binding.value); + sortableDirective.mounted(); + }, + updated(el: HTMLElement & { [props: string]: any }, binding: DirectiveBinding) { + const sortableDirective = el[SortableDirective.INSTANCE_KEY] as SortableDirective; + sortableDirective.updateInput(binding.value, binding.oldValue!); + }, +}; diff --git a/packages/devui-vue/devui/dragdrop-new/src/sync/desc-reg.service.ts b/packages/devui-vue/devui/dragdrop-new/src/sync/desc-reg.service.ts new file mode 100644 index 0000000000..597be2ea6e --- /dev/null +++ b/packages/devui-vue/devui/dragdrop-new/src/sync/desc-reg.service.ts @@ -0,0 +1,71 @@ +import { BehaviorSubject, Observable, Subject, Subscription, debounceTime } from 'rxjs'; +import { QueryList } from './query-list'; +import { NgDirectiveBase } from '../directive-base'; + +export class DescendantRegisterService { + protected _result: Array = []; + protected changeSubject: Subject> = new BehaviorSubject>([]); + public changes: Observable> = this.changeSubject.asObservable().pipe(debounceTime(200)); + public register(t: T) { + if (!t) { + return; + } + const index = this._result.indexOf(t); + if (index === -1) { + this._result.push(t); + this.changeSubject.next(this._result); + } + } + public unregister(t: T) { + if (!t) { + return; + } + const index = this._result.indexOf(t); + if (index > -1) { + this._result.splice(index, 1); + this.changeSubject.next(this._result); + } + } + public queryResult() { + return this._result.concat([]); + } +} + +export class DescendantChildren< + T, + I extends { [prop: string]: any } = { [prop: string]: any }, + O = { [prop: string]: (e: any) => void } +> extends NgDirectiveBase { + constructor(private drs: DescendantRegisterService) { + super(); + } + protected descendantItem: T; + ngOnInit() { + this.drs.register(this.descendantItem); + } + ngOnDestroy() { + this.drs.unregister(this.descendantItem); + } +} + +export class DescendantRoot extends QueryList { + protected sub: Subscription; + constructor(private drs: DescendantRegisterService) { + super(); + } + public on() { + if (this.sub) { + return; + } + this.reset(this.drs.queryResult()); + this.sub = this.drs.changes.subscribe((result) => { + this.reset(result); + }); + } + + public off() { + if (this.sub) { + this.sub.unsubscribe(); + } + } +} diff --git a/packages/devui-vue/devui/dragdrop-new/src/sync/drag-drop-descendant-sync.service.ts b/packages/devui-vue/devui/dragdrop-new/src/sync/drag-drop-descendant-sync.service.ts new file mode 100644 index 0000000000..6c29ab5d84 --- /dev/null +++ b/packages/devui-vue/devui/dragdrop-new/src/sync/drag-drop-descendant-sync.service.ts @@ -0,0 +1,11 @@ +import { DragSyncDirective } from './drag-sync.directive'; +import { DropSortSyncDirective } from './drop-sort-sync.directive'; +import { DescendantRegisterService } from './desc-reg.service'; +import { InjectionKey } from 'vue'; + +export class DragSyncDescendantRegisterService extends DescendantRegisterService { + static TOKEN: InjectionKey = Symbol('DRAG_SYNC_DR_SERVICE'); +} +export class DropSortSyncDescendantRegisterService extends DescendantRegisterService { + static TOKEN: InjectionKey = Symbol('DROP_SORT_SYNC_DR_SERVICE'); +} diff --git a/packages/devui-vue/devui/dragdrop-new/src/sync/drag-drop-sync-box.directive.ts b/packages/devui-vue/devui/dragdrop-new/src/sync/drag-drop-sync-box.directive.ts new file mode 100644 index 0000000000..9e99c2feed --- /dev/null +++ b/packages/devui-vue/devui/dragdrop-new/src/sync/drag-drop-sync-box.directive.ts @@ -0,0 +1,80 @@ +import { DirectiveBinding, inject } from 'vue'; +import { Subscription } from 'rxjs'; +import { DescendantRoot } from './desc-reg.service'; +import { DragSyncDescendantRegisterService, DropSortSyncDescendantRegisterService } from './drag-drop-descendant-sync.service'; +import { DragDropSyncService } from './drag-drop-sync.service'; +import { DragSyncDirective } from './drag-sync.directive'; +import { DropSortSyncDirective } from './drop-sort-sync.directive'; +import { NgDirectiveBase } from '../directive-base'; +import { injectFromContext, provideToContext } from '../utils'; + +export class DragDropSyncBoxDirective extends NgDirectiveBase { + static INSTANCE_KEY = '__vueDevuiDragDropSyncBoxDirectiveInstance'; + sub = new Subscription(); + dragSyncList: DescendantRoot; + dropSyncList: DescendantRoot; + constructor( + private dragDropSyncService: DragDropSyncService, + private dragSyncDrs: DragSyncDescendantRegisterService, + private dropSortSyncDrs: DropSortSyncDescendantRegisterService + ) { + super(); + } + + ngOnInit() { + this.dragSyncList = new DescendantRoot(this.dragSyncDrs); + this.dropSyncList = new DescendantRoot(this.dropSortSyncDrs); + } + ngAfterViewInit() { + this.dragSyncList.on(); + this.dropSyncList.on(); + this.dragDropSyncService.updateDragSyncList(this.dragSyncList); + this.dragDropSyncService.updateDropSyncList(this.dropSyncList); + this.sub.add(this.dragSyncList.changes.subscribe((list) => this.dragDropSyncService.updateDragSyncList(list))); + this.sub.add(this.dropSyncList.changes.subscribe((list) => this.dragDropSyncService.updateDropSyncList(list))); + } + ngOnDestroy() { + if (this.sub) { + this.sub.unsubscribe(); + } + this.dragSyncList.off(); + this.dropSyncList.off(); + } +} + +export default { + created(el: HTMLElement & { [props: string]: any }, binding: DirectiveBinding<{}>, vNode) { + const context = vNode['ctx'].provides; + const dragDropSyncService = new DragDropSyncService(); + const dragSyncDrs = new DragSyncDescendantRegisterService(); + const dropSortSyncDrs = new DropSortSyncDescendantRegisterService(); + provideToContext(DragDropSyncService.TOKEN, dragDropSyncService, context); + provideToContext(DragSyncDescendantRegisterService.TOKEN, dragSyncDrs, context); + provideToContext(DropSortSyncDescendantRegisterService.TOKEN, dropSortSyncDrs, context); + }, + mounted(el: HTMLElement & { [props: string]: any }, binding: DirectiveBinding<{}>, vNode) { + const context = vNode['ctx'].provides; + const dragDropSyncService = injectFromContext(DragDropSyncService.TOKEN, context)!; + const dragSyncDrs = injectFromContext(DragSyncDescendantRegisterService.TOKEN, context)!; + const dropSortSyncDrs = injectFromContext(DropSortSyncDescendantRegisterService.TOKEN, context)!; + const dragDropSyncBoxDirective = (el[DragDropSyncBoxDirective.INSTANCE_KEY] = new DragDropSyncBoxDirective( + dragDropSyncService, + dragSyncDrs, + dropSortSyncDrs + )); + dragDropSyncBoxDirective.setInput(binding.value); + dragDropSyncBoxDirective.mounted(); + dragDropSyncBoxDirective.ngOnInit?.(); + setTimeout(() => { + dragDropSyncBoxDirective.ngAfterViewInit?.(); + }, 0); + }, + updated(el: HTMLElement & { [props: string]: any }, binding: DirectiveBinding<{}>) { + const dragDropSyncBoxDirective = el[DragDropSyncBoxDirective.INSTANCE_KEY] as DragDropSyncBoxDirective; + dragDropSyncBoxDirective.updateInput(binding.value, binding.oldValue!); + }, + beforeUnmount(el: HTMLElement & { [props: string]: any }) { + const dragDropSyncBoxDirective = el[DragDropSyncBoxDirective.INSTANCE_KEY] as DragDropSyncBoxDirective; + dragDropSyncBoxDirective.ngOnDestroy?.(); + }, +}; diff --git a/packages/devui-vue/devui/dragdrop-new/src/sync/drag-drop-sync.service.ts b/packages/devui-vue/devui/dragdrop-new/src/sync/drag-drop-sync.service.ts new file mode 100644 index 0000000000..d1012303b7 --- /dev/null +++ b/packages/devui-vue/devui/dragdrop-new/src/sync/drag-drop-sync.service.ts @@ -0,0 +1,30 @@ +import { InjectionKey } from 'vue'; +import { DragSyncDirective } from './drag-sync.directive'; +import { DropSortSyncDirective } from './drop-sort-sync.directive'; +import { QueryList } from './query-list'; + +export class DragDropSyncService { + static TOKEN: InjectionKey = Symbol('DRAG_DROP_SYNC_SERVICE'); + dragSyncList: QueryList; + dropSortSyncList: QueryList; + + public updateDragSyncList(list: QueryList) { + this.dragSyncList = list; + } + public getDragSyncByGroup(groupName: string) { + if (groupName) { + return []; + } + return this.dragSyncList ? this.dragSyncList.filter((dragSync) => dragSync.dragSyncGroup === groupName) : []; + } + + public updateDropSyncList(list: QueryList) { + this.dropSortSyncList = list; + } + public getDropSyncByGroup(groupName: string) { + if (groupName) { + return []; + } + return this.dropSortSyncList ? this.dropSortSyncList.filter((dragSync) => dragSync.dropSyncGroup === groupName) : []; + } +} diff --git a/packages/devui-vue/devui/dragdrop-new/src/sync/drag-sync.directive.ts b/packages/devui-vue/devui/dragdrop-new/src/sync/drag-sync.directive.ts new file mode 100644 index 0000000000..31f5d5f4b5 --- /dev/null +++ b/packages/devui-vue/devui/dragdrop-new/src/sync/drag-sync.directive.ts @@ -0,0 +1,104 @@ +import { DirectiveBinding } from 'vue'; +import { Subscription } from 'rxjs'; +import { DescendantChildren } from './desc-reg.service'; +import { DragSyncDescendantRegisterService } from './drag-drop-descendant-sync.service'; +import { DragDropSyncService } from './drag-drop-sync.service'; +import { DragDropService } from '../drag-drop.service'; +import { DraggableDirective } from '../draggable.directive'; +import { injectFromContext } from '../utils'; + +export interface IDragSyncBinding { + dragSync?: string; + dragSyncGroup?: string; + [props: string]: any; +} +export class DragSyncDirective extends DescendantChildren { + static INSTANCE_KEY = '__vueDevuiDragSyncDirectiveInstance'; + inputNameMap?: { [key: string]: string } | undefined = { + dragSync: 'dragSyncGroup', // 保持原有api可以正常使用 + dragSyncGroup: 'dragSyncGroup', // 别名,更好理解 + }; + dragSyncGroup = ''; + subscription: Subscription = new Subscription(); + syncGroupDirectives?: Array; + public el: { nativeElement: any } = { nativeElement: null }; + constructor( + el: HTMLElement, + // @Optional() @Self() + private draggable: DraggableDirective, + private dragDropSyncService: DragDropSyncService, + private dragDropService: DragDropService, + dragSyncDrs: DragSyncDescendantRegisterService + ) { + super(dragSyncDrs); + this.el.nativeElement = el; + this.descendantItem = this; + } + + ngOnInit() { + if (this.draggable) { + this.subscription.add(this.draggable.dragElShowHideEvent.subscribe(this.subDragElEvent)); + this.subscription.add( + this.draggable.beforeDragStartEvent.subscribe(() => { + this.syncGroupDirectives = this.dragDropSyncService + .getDragSyncByGroup(this.dragSyncGroup) + .filter((directive) => directive !== this); + this.dragDropService.dragSyncGroupDirectives = this.syncGroupDirectives; + }) + ); + this.subscription.add( + this.draggable.dropEndEvent.subscribe(() => { + this.dragDropService.dragSyncGroupDirectives = undefined; + this.syncGroupDirectives = undefined; + }) + ); + } + super.ngOnInit(); + } + + ngOnDestroy() { + if (this.subscription) { + this.subscription.unsubscribe(); + } + super.ngOnDestroy(); + } + + subDragElEvent = (bool: boolean) => { + this.syncGroupDirectives!.forEach((dir) => this.renderDisplay(dir.el.nativeElement, bool)); + }; + + renderDisplay(nativeEl: HTMLElement, bool: boolean) { + nativeEl.style.display = bool ? '' : 'none'; + } +} + +export default { + mounted(el: HTMLElement & { [props: string]: any }, binding: DirectiveBinding, vNode) { + const context = vNode['ctx'].provides; + let draggable = injectFromContext(DraggableDirective.TOKEN, context) as DraggableDirective | undefined; + if (draggable?.el.nativeElement !== el) { + draggable = undefined; + } + const dragDropSyncService = injectFromContext(DragDropSyncService.TOKEN, context) as DragDropSyncService; + const dragDropService = injectFromContext(DragDropService.TOKEN, context) as DragDropService; + const dragSyncDrs = injectFromContext(DragSyncDescendantRegisterService.TOKEN, context) as DragSyncDescendantRegisterService; + const dragSyncDirective = (el[DragSyncDirective.INSTANCE_KEY] = new DragSyncDirective( + el, + draggable!, + dragDropSyncService, + dragDropService, + dragSyncDrs + )); + dragSyncDirective.setInput(binding.value); + dragSyncDirective.mounted(); + dragSyncDirective.ngOnInit?.(); + }, + updated(el: HTMLElement & { [props: string]: any }, binding: DirectiveBinding) { + const dragSyncDirective = el[DragSyncDirective.INSTANCE_KEY] as DragSyncDirective; + dragSyncDirective.updateInput(binding.value, binding.oldValue!); + }, + beforeUnmount(el: HTMLElement & { [props: string]: any }) { + const dragSyncDirective = el[DragSyncDirective.INSTANCE_KEY] as DragSyncDirective; + dragSyncDirective.ngOnDestroy?.(); + }, +}; diff --git a/packages/devui-vue/devui/dragdrop-new/src/sync/drop-sort-sync.directive.ts b/packages/devui-vue/devui/dragdrop-new/src/sync/drop-sort-sync.directive.ts new file mode 100644 index 0000000000..343f38a3c9 --- /dev/null +++ b/packages/devui-vue/devui/dragdrop-new/src/sync/drop-sort-sync.directive.ts @@ -0,0 +1,147 @@ +import { DirectiveBinding } from 'vue'; +import { Subscription } from 'rxjs'; +import { DescendantChildren } from './desc-reg.service'; +import { DropSortSyncDescendantRegisterService } from './drag-drop-descendant-sync.service'; +import { DragDropSyncService } from './drag-drop-sync.service'; +import { Utils, injectFromContext } from '../utils'; +import { DroppableDirective, type DragPlaceholderInsertionEvent, type DragPlaceholderInsertionIndexEvent } from '../droppable.directive'; + +export interface IDropSortSyncBinding { + dropSortSync?: string; + dropSyncGroup?: string; + dropSyncDirection?: 'v' | 'h'; + [props: string]: any; +} +export class DropSortSyncDirective extends DescendantChildren { + static INSTANCE_KEY = '__vueDevuiDropSortSyncDirectiveInstance'; + inputNameMap?: { [key: string]: string } | undefined = { + dropSortSync: 'dropSyncGroup', + dropSyncGroup: 'dropSyncGroup', + dropSyncDirection: 'direction', + }; + dropSyncGroup = ''; + direction: 'v' | 'h' = 'v'; // 与sortContainer正交的方向 + subscription: Subscription = new Subscription(); + syncGroupDirectives: Array; + placeholder: HTMLElement; + sortContainer: HTMLElement; + public el: { nativeElement: any } = { nativeElement: null }; + constructor( + el: HTMLElement, + // @Optional() @Self() + private droppable: DroppableDirective, + private dragDropSyncService: DragDropSyncService, + dropSortSyncDrs: DropSortSyncDescendantRegisterService + ) { + super(dropSortSyncDrs); + this.el.nativeElement = el; + this.descendantItem = this; + } + + ngOnInit() { + this.sortContainer = this.el.nativeElement; + if (this.droppable) { + this.sortContainer = this.droppable.getSortContainer(); + this.subscription.add(this.droppable.placeholderInsertionEvent.subscribe(this.subInsertionEvent)); + this.subscription.add(this.droppable.placeholderRenderEvent.subscribe(this.subRenderEvent)); + } + super.ngOnInit(); + } + + ngOnDestroy() { + if (this.subscription) { + this.subscription.unsubscribe(); + } + super.ngOnDestroy(); + } + subRenderEvent = (nativeStyle: { width: number; height: number }) => { + this.syncGroupDirectives = this.dragDropSyncService.getDropSyncByGroup(this.dropSyncGroup).filter((directive) => directive !== this); + this.syncGroupDirectives.forEach((dir) => { + dir.renderPlaceholder(nativeStyle, this.droppable); + }); + }; + + subInsertionEvent = (cmd: DragPlaceholderInsertionIndexEvent) => { + this.syncGroupDirectives = this.dragDropSyncService.getDropSyncByGroup(this.dropSyncGroup).filter((directive) => directive !== this); + this.syncGroupDirectives.forEach((dir) => { + dir.insertPlaceholderCommand({ + command: cmd.command, + container: dir.sortContainer, + relatedEl: dir.getChildrenElByIndex(dir.sortContainer, cmd.index)!, + }); + }); + }; + getChildrenElByIndex(target, index?) { + if (index === undefined || (target && target.children && target.children.length < index) || index < 0) { + return null; + } + return this.sortContainer.children.item(index); + } + + renderPlaceholder(nativeStyle: { width: number; height: number }, droppable) { + if (!this.placeholder) { + this.placeholder = document.createElement(droppable.placeholderTag); + this.placeholder.className = 'drag-placeholder'; + this.placeholder.classList.add('drag-sync-placeholder'); + this.placeholder.innerText = droppable.placeholderText; + } + const { width, height } = nativeStyle; + if (this.direction === 'v') { + this.placeholder.style.width = width + 'px'; + this.placeholder.style.height = this.sortContainer.getBoundingClientRect().height + 'px'; + } else { + this.placeholder.style.height = height + 'px'; + this.placeholder.style.width = this.sortContainer.getBoundingClientRect().width + 'px'; + } + Utils.addElStyles(this.placeholder, droppable.placeholderStyle); + } + + insertPlaceholderCommand(cmd: DragPlaceholderInsertionEvent) { + if (cmd.command === 'insertBefore' && cmd.container) { + cmd.container.insertBefore(this.placeholder, cmd.relatedEl!); + return; + } + if (cmd.command === 'append' && cmd.container) { + cmd.container.appendChild(this.placeholder); + return; + } + if (cmd.command === 'remove' && cmd.container) { + if (cmd.container.contains(this.placeholder)) { + cmd.container.removeChild(this.placeholder); + } + return; + } + } +} + +export default { + mounted(el: HTMLElement & { [props: string]: any }, binding: DirectiveBinding, vNode) { + const context = vNode['ctx'].provides; + let droppable = injectFromContext(DroppableDirective.TOKEN, context) as DroppableDirective | undefined; + if (droppable?.el.nativeElement !== el) { + droppable = undefined; + } + const dragDropSyncService = injectFromContext(DragDropSyncService.TOKEN, context) as DragDropSyncService; + const dropSortSyncDrs = injectFromContext( + DropSortSyncDescendantRegisterService.TOKEN, + context + ) as DropSortSyncDescendantRegisterService; + const dropSortSyncDirective = (el[DropSortSyncDirective.INSTANCE_KEY] = new DropSortSyncDirective( + el, + droppable!, + dragDropSyncService, + dropSortSyncDrs + )); + dropSortSyncDirective.setInput(binding.value); + dropSortSyncDirective.mounted(); + dropSortSyncDirective.ngOnInit?.(); + }, + updated(el: HTMLElement & { [props: string]: any }, binding: DirectiveBinding) { + const dropSortSyncDirective = el[DropSortSyncDirective.INSTANCE_KEY] as DropSortSyncDirective; + dropSortSyncDirective.updateInput(binding.value, binding.oldValue!); + }, + beforeUnmount(el: HTMLElement & { [props: string]: any }) { + const dropSortSyncDirective = el[DropSortSyncDirective.INSTANCE_KEY] as DropSortSyncDirective; + dropSortSyncDirective.ngOnDestroy?.(); + }, +}; diff --git a/packages/devui-vue/devui/dragdrop-new/src/sync/index.ts b/packages/devui-vue/devui/dragdrop-new/src/sync/index.ts new file mode 100644 index 0000000000..95976f9fb6 --- /dev/null +++ b/packages/devui-vue/devui/dragdrop-new/src/sync/index.ts @@ -0,0 +1,23 @@ +import { App } from 'vue'; +import { default as DragDropSyncBox } from './drag-drop-sync-box.directive'; +import { default as DragSync } from './drag-sync.directive'; +import { default as DropSortSync } from './drop-sort-sync.directive'; + +export * from './desc-reg.service'; +export * from './drag-drop-descendant-sync.service'; +export * from './drag-drop-sync.service'; +export * from './drag-drop-sync-box.directive'; +export * from './drag-sync.directive'; +export * from './drop-sort-sync.directive'; + +export { DragDropSyncBox, DragSync, DropSortSync }; + +export default { + title: 'DragDropSync 拖拽同步', + category: '基础组件', + install(app: App): void { + app.directive('dDragDropSyncBox', DragDropSyncBox); + app.directive('dDragSync', DragSync); + app.directive('dDropSortSync', DropSortSync); + }, +}; diff --git a/packages/devui-vue/devui/dragdrop-new/src/sync/query-list.ts b/packages/devui-vue/devui/dragdrop-new/src/sync/query-list.ts new file mode 100644 index 0000000000..4b9c270099 --- /dev/null +++ b/packages/devui-vue/devui/dragdrop-new/src/sync/query-list.ts @@ -0,0 +1,115 @@ +import { Observable } from 'rxjs'; +import { EventEmitter } from '../preserve-next-event-emitter'; + +/** + * Flattens an array. + */ +export function flatten(list: any[], dst?: any[]): any[] { + if (dst === undefined) dst = list; + for (let i = 0; i < list.length; i++) { + let item = list[i]; + if (Array.isArray(item)) { + // we need to inline it. + if (dst === list) { + // Our assumption that the list was already flat was wrong and + // we need to clone flat since we need to write to it. + dst = list.slice(0, i); + } + flatten(item, dst); + } else if (dst !== list) { + dst.push(item); + } + } + return dst; +} + +function symbolIterator(this: QueryList): Iterator { + return ((this as any as { _results: Array })._results as any)[Symbol.iterator](); +} + +export class QueryList implements Iterable { + public readonly dirty = true; + private _results: Array = []; + public readonly changes: Observable = new EventEmitter(); + + readonly length: number = 0; + readonly first!: T; + readonly last!: T; + + constructor() { + const symbol = Symbol.iterator; + const proto = QueryList.prototype as any; + if (!proto[symbol]) proto[symbol] = symbolIterator; + } + + // proxy array method to property '_results' + map(fn: (item: T, index: number, array: T[]) => U): U[] { + return this._results.map(fn); + } + filter(fn: (item: T, index: number, array: T[]) => boolean): T[] { + return this._results.filter(fn); + } + find(fn: (item: T, index: number, array: T[]) => boolean): T | undefined { + return this._results.find(fn); + } + reduce(fn: (prevValue: U, curValue: T, curIndex: number, array: T[]) => U, init: U): U { + return this._results.reduce(fn, init); + } + forEach(fn: (item: T, index: number, array: T[]) => void): void { + this._results.forEach(fn); + } + some(fn: (value: T, index: number, array: T[]) => boolean): boolean { + return this._results.some(fn); + } + + /** + * Returns a copy of the internal results list as an Array. + */ + toArray(): T[] { + return this._results.slice(); + } + + toString(): string { + return this._results.toString(); + } + + /** + * Updates the stored data of the query list, and resets the `dirty` flag to `false`, so that + * on change detection, it will not notify of changes to the queries, unless a new change + * occurs. + * + * @param resultsTree The query results to store + */ + reset(resultsTree: Array): void { + this._results = flatten(resultsTree); + (this as { dirty: boolean }).dirty = false; + (this as { length: number }).length = this._results.length; + (this as { last: T }).last = this._results[this.length - 1]; + (this as { first: T }).first = this._results[0]; + } + + /** + * Triggers a change event by emitting on the `changes` {@link EventEmitter}. + */ + notifyOnChanges(): void { + (this.changes as EventEmitter).emit(this); + } + + /** internal */ + setDirty() { + (this as { dirty: boolean }).dirty = true; + } + + /** internal */ + destroy(): void { + (this.changes as EventEmitter).complete(); + (this.changes as EventEmitter).unsubscribe(); + } + + // The implementation of `Symbol.iterator` should be declared here, but this would cause + // tree-shaking issues with `QueryList. So instead, it's added in the constructor (see comments + // there) and this declaration is left here to ensure that TypeScript considers QueryList to + // implement the Iterable interface. This is required for template type-checking of NgFor loops + // over QueryLists to work correctly, since QueryList must be assignable to NgIterable. + [Symbol.iterator]!: () => Iterator; +} diff --git a/packages/devui-vue/devui/dragdrop-new/src/touch-support/dragdrop-touch.ts b/packages/devui-vue/devui/dragdrop-new/src/touch-support/dragdrop-touch.ts new file mode 100644 index 0000000000..ac88e9a12c --- /dev/null +++ b/packages/devui-vue/devui/dragdrop-new/src/touch-support/dragdrop-touch.ts @@ -0,0 +1,576 @@ +/** + * 2020.03.23-Modified from https://github.com/Bernardo-Castilho/dragdroptouch, license: MIT,reason:Converting .js file to .ts file + */ +export class DragDropTouch { + static readonly THRESHOLD = 5; // pixels to move before drag starts + static readonly OPACITY = 0.5; // drag image opacity + static readonly DBLCLICK = 500; // max ms between clicks in a double click + static readonly DRAG_OVER_TIME = 300; // interval ms when drag over + static readonly CTX_MENU = 900; // ms to hold before raising 'contextmenu' event + static readonly IS_PRESS_HOLD_MODE = true; // decides of press & hold mode presence + static readonly PRESS_HOLD_AWAIT = 400; // ms to wait before press & hold is detected + static readonly PRESS_HOLD_MARGIN = 25; // pixels that finger might shiver while pressing + static readonly PRESS_HOLD_THRESHOLD = 0; // pixels to move before drag starts + static readonly DRAG_HANDLE_ATTR = 'data-drag-handle-selector'; + + static readonly rmvAttrs = 'id,class,style,draggable'.split(','); + static readonly kbdProps = 'altKey,ctrlKey,metaKey,shiftKey'.split(','); + static readonly ptProps = 'pageX,pageY,clientX,clientY,screenX,screenY'.split(','); + private static instance: DragDropTouch | null = null; + + dataTransfer: DataTransfer; + lastClick = 0; + lastTouch: TouchEvent | null; + // touched element + lastTarget: HTMLElement | null; + // touched draggable element + dragSource: HTMLElement | null; + ptDown: { x: number; y: number } | null; + isDragEnabled: boolean; + isDropZone: boolean; + pressHoldInterval; + img; + imgCustom; + imgOffset; + // for continual drag over event even touch point stop at a certain point for a while. + dragoverTimer; + // for bind touch move and touch end event to touch target incase virtual scroll cause + // document no longer get capture/ bubble of touchmove event from dom removed from document tree + touchTarget?: EventTarget; + touchmoveListener: EventListener; + touchendListener: EventListener; + listenerOpt: boolean | EventListenerOptions; + + constructor() { + // enforce singleton pattern + if (DragDropTouch.instance) { + throw new Error('DragDropTouch instance already created.'); + } + // detect passive event support + // https://github.com/Modernizr/Modernizr/issues/1894 + let supportsPassive = false; + if (typeof document !== 'undefined') { + document.addEventListener('test', () => {}, { + get passive() { + supportsPassive = true; + return true; + }, + }); + // listen to touch events + if (DragDropTouch.isTouchDevice()) { + // 能响应触摸事件 + const d = document; + const ts = this.touchstart; + const tmod = this.touchmoveOnDocument; + const teod = this.touchendOnDocument; + const opt = supportsPassive ? { passive: false, capture: false } : false; + const optPassive = supportsPassive ? { passive: true } : false; + d.addEventListener('touchstart', ts, opt); + d.addEventListener('touchmove', tmod, optPassive); + d.addEventListener('touchend', teod); + d.addEventListener('touchcancel', teod); + this.touchmoveListener = this.touchmove as EventListener; + this.touchendListener = this.touchend as EventListener; + this.listenerOpt = opt; + } + } + } + /** + * Gets a reference to the @see:DragDropTouch singleton. + */ + static getInstance() { + if (!DragDropTouch.instance) { + DragDropTouch.instance = new DragDropTouch(); + } + return DragDropTouch.instance; + } + static isTouchDevice() { + if (typeof window === 'undefined' || typeof document === 'undefined') { + return false; + } + const d: Document = document; + const w: Window = window; + let bool; + if ( + 'ontouchstart' in d || // normal mobile device + 'ontouchstart' in w || + navigator.maxTouchPoints > 0 || + navigator['msMaxTouchPoints'] > 0 || + (window['DocumentTouch'] && document instanceof window['DocumentTouch']) + ) { + bool = true; + } else { + const fakeBody = document.createElement('fakebody'); + fakeBody.innerHTML += ` + `; + document.documentElement.appendChild(fakeBody); + const touchTestNode = document.createElement('div'); + touchTestNode.id = 'touch_test'; + fakeBody.appendChild(touchTestNode); + bool = touchTestNode.offsetTop === 42; + fakeBody.parentElement?.removeChild(fakeBody); + } + return bool; + } + // ** event listener binding + bindTouchmoveTouchend(e: TouchEvent) { + this.touchTarget = e.target!; + (e.target as Node).addEventListener('touchmove', this.touchmoveListener, this.listenerOpt); + (e.target as Node).addEventListener('touchend', this.touchendListener); + (e.target as Node).addEventListener('touchcancel', this.touchendListener); + } + removeTouchmoveTouchend() { + if (this.touchTarget) { + this.touchTarget.removeEventListener('touchmove', this.touchmoveListener); + this.touchTarget.removeEventListener('touchend', this.touchendListener); + this.touchTarget.removeEventListener('touchcancel', this.touchendListener); + this.touchTarget = undefined; + } + } + // ** event handlers + touchstart = (e: TouchEvent) => { + if (this.shouldHandle(e)) { + // raise double-click and prevent zooming + if (Date.now() - this.lastClick < DragDropTouch.DBLCLICK) { + if (this.dispatchEvent(e, 'dblclick', e.target)) { + e.preventDefault(); + this.reset(); + return; + } + } + // clear all variables + this.reset(); + // get nearest draggable element + const src = this.closestDraggable(e.target); + if (src) { + this.dragSource = src; + this.ptDown = this.getPoint(e); + this.lastTouch = e; + if (DragDropTouch.IS_PRESS_HOLD_MODE) { + this.pressHoldInterval = setTimeout(() => { + this.bindTouchmoveTouchend(e); + this.isDragEnabled = true; + this.touchmove(e); + }, DragDropTouch.PRESS_HOLD_AWAIT); + } else { + e.preventDefault(); + this.bindTouchmoveTouchend(e); + } + } + } + }; + touchmoveOnDocument = (e) => { + if (this.shouldCancelPressHoldMove(e)) { + this.reset(); + return; + } + }; + touchmove = (e: TouchEvent) => { + if (this.shouldCancelPressHoldMove(e)) { + this.reset(); + return; + } + if (this.shouldHandleMove(e) || this.shouldHandlePressHoldMove(e)) { + const target = this.getTarget(e); + // start dragging + if (this.dragSource && !this.img && this.shouldStartDragging(e)) { + this.dispatchEvent(e, 'dragstart', this.dragSource); + this.createImage(e); + } + // continue dragging + if (this.img) { + this.clearDragoverInterval(); + this.lastTouch = e; + e.preventDefault(); // prevent scrolling + if (target !== this.lastTarget) { + // according to drag drop implementation of the browser, dragenterB is supposed to fired before dragleaveA + this.dispatchEvent(e, 'dragenter', target); + this.dispatchEvent(this.lastTouch, 'dragleave', this.lastTarget); + this.lastTarget = target; + } + this.moveImage(e); + this.isDropZone = this.dispatchEvent(e, 'dragover', target); + // should continue dispatch dragover event when touch position stay still + this.setDragoverInterval(e); + } + } + }; + touchendOnDocument = (e) => { + if (this.shouldHandle(e)) { + if (!this.img) { + this.dragSource = null; + this.lastClick = Date.now(); + } + // finish dragging + this.destroyImage(); + if (this.dragSource) { + this.reset(); + } + } + }; + touchend = (e) => { + if (this.shouldHandle(e)) { + // user clicked the element but didn't drag, so clear the source and simulate a click + if (!this.img) { + this.dragSource = null; + // browser will dispatch click event after trigger touchend, since touchstart didn't preventDefault + this.lastClick = Date.now(); + } + // finish dragging + this.destroyImage(); + if (this.dragSource) { + if (e.type.indexOf('cancel') < 0 && this.isDropZone) { + this.dispatchEvent(this.lastTouch, 'drop', this.lastTarget); + } + this.dispatchEvent(this.lastTouch, 'dragend', this.dragSource); + this.reset(); + } + } + }; + // ** utilities + // ignore events that have been handled or that involve more than one touch + shouldHandle(e) { + return e && !e.defaultPrevented && e.touches && e.touches.length < 2; + } + // use regular condition outside of press & hold mode + shouldHandleMove(e) { + return !DragDropTouch.IS_PRESS_HOLD_MODE && this.shouldHandle(e); + } + // allow to handle moves that involve many touches for press & hold + shouldHandlePressHoldMove(e) { + return DragDropTouch.IS_PRESS_HOLD_MODE && this.isDragEnabled && e && e.touches && e.touches.length; + } + // reset data if user drags without pressing & holding + shouldCancelPressHoldMove(e) { + return DragDropTouch.IS_PRESS_HOLD_MODE && !this.isDragEnabled && this.getDelta(e) > DragDropTouch.PRESS_HOLD_MARGIN; + } + // start dragging when mouseover element matches drag handler selector and specified delta is detected + shouldStartDragging(e) { + const dragHandleSelector = this.getDragHandle(); + // start dragging when mouseover element matches drag handler selector + if (dragHandleSelector && !this.matchSelector(e.target, dragHandleSelector)) { + return false; + } + // start dragging when specified delta is detected + const delta = this.getDelta(e); + return delta > DragDropTouch.THRESHOLD || (DragDropTouch.IS_PRESS_HOLD_MODE && delta >= DragDropTouch.PRESS_HOLD_THRESHOLD); + } + // find drag handler selector for dragstart only with partial element + getDragHandle() { + if (this.dragSource) { + return this.dragSource.getAttribute(DragDropTouch.DRAG_HANDLE_ATTR) || ''; + } + return ''; + } + // test if element matches selector + matchSelector(element, selector) { + if (selector) { + const proto = Element.prototype; + const func = + proto['matches'] || + proto['matchesSelector'] || + proto['mozMatchesSelector'] || + proto['msMatchesSelector'] || + proto['oMatchesSelector'] || + proto['webkitMatchesSelector'] || + function (s) { + const matches = (this.document || this.ownerDocument).querySelectorAll(s); + let i = matches.length; + while (--i >= 0 && matches.item(i) !== this) { + // do nothing + } + return i > -1; + }; + return func.call(element, selector); + } + return true; + } + // clear all members + reset() { + this.removeTouchmoveTouchend(); + this.destroyImage(); + this.dragSource = null; + this.lastTouch = null; + this.lastTarget = null; + this.ptDown = null; + this.isDragEnabled = false; + this.isDropZone = false; + this.dataTransfer = new DragDropTouch.DataTransfer(); + clearInterval(this.pressHoldInterval); + this.clearDragoverInterval(); + } + // get point for a touch event + getPoint(e, page?) { + if (e && e.touches) { + e = e.touches[0]; + } + return { x: page ? e.pageX : e.clientX, y: page ? e.pageY : e.clientY }; + } + // get distance between the current touch event and the first one + getDelta(e) { + if (DragDropTouch.IS_PRESS_HOLD_MODE && !this.ptDown) { + return 0; + } + const p = this.getPoint(e); + return Math.abs(p.x - this.ptDown!.x) + Math.abs(p.y - this.ptDown!.y); + } + // get the element at a given touch event + getTarget(e: TouchEvent) { + const pt = this.getPoint(e); + let el = document.elementFromPoint(pt.x, pt.y); + while (el && getComputedStyle(el).pointerEvents === 'none') { + el = el.parentElement; + } + return el; + } + // create drag image from source element + createImage(e) { + // just in case... + if (this.img) { + this.destroyImage(); + } + // create drag image from custom element or drag source + const src = this.imgCustom || this.dragSource; + this.img = src.cloneNode(true); + this.copyStyle(src, this.img); + this.img.style.top = this.img.style.left = '-9999px'; + // if creating from drag source, apply offset and opacity + if (!this.imgCustom) { + const rc = src.getBoundingClientRect(); + const pt = this.getPoint(e); + this.imgOffset = { x: pt.x - rc.left, y: pt.y - rc.top }; + this.img.style.opacity = DragDropTouch.OPACITY.toString(); + } + // add image to document + this.moveImage(e); + document.body.appendChild(this.img); + } + // dispose of drag image element + destroyImage() { + if (this.img && this.img.parentElement) { + this.img.parentElement.removeChild(this.img); + } + this.img = null; + this.imgCustom = null; + } + // move the drag image element + moveImage(e) { + requestAnimationFrame(() => { + if (this.img) { + const pt = this.getPoint(e, true); + const s = this.img.style; + s.position = 'absolute'; + s.pointerEvents = 'none'; + s.zIndex = '999999'; + s.left = Math.round(pt.x - this.imgOffset.x) + 'px'; + s.top = Math.round(pt.y - this.imgOffset.y) + 'px'; + } + }); + } + // copy properties from an object to another + copyProps(dst, src, props) { + for (let i = 0; i < props.length; i++) { + const p = props[i]; + dst[p] = src[p]; + } + } + // copy styles/attributes from drag source to drag image element + copyStyle(src, dst) { + // remove potentially troublesome attributes + DragDropTouch.rmvAttrs.forEach(function (att) { + dst.removeAttribute(att); + }); + // copy canvas content + if (src instanceof HTMLCanvasElement) { + const canSrc = src; + const canDst = dst; + canDst.width = canSrc.width; + canDst.height = canSrc.height; + canDst.getContext('2d').drawImage(canSrc, 0, 0); + } + // copy canvas content for nested canvas element + const srcCanvases = src.querySelectorAll('canvas'); + if (srcCanvases.length > 0) { + const dstCanvases = dst.querySelectorAll('canvas'); + for (let i = 0; i < dstCanvases.length; i++) { + const cSrc = srcCanvases[i]; + const cDst = dstCanvases[i]; + cDst.getContext('2d').drawImage(cSrc, 0, 0); + } + } + // copy style (without transitions) + const cs = getComputedStyle(src); + for (let i = 0; i < cs.length; i++) { + const key = cs[i]; + if (key.indexOf('transition') < 0) { + dst.style[key] = cs[key]; + } + } + dst.style.pointerEvents = 'none'; + // and repeat for all children + for (let i = 0; i < src.children.length; i++) { + this.copyStyle(src.children[i], dst.children[i]); + } + } + // synthesize and dispatch an event + // returns true if the event has been handled (e.preventDefault == true) + dispatchEvent(e, type, target) { + if (e && target) { + const evt = document.createEvent('Event'); + const t = e.touches ? e.touches[0] : e; + evt.initEvent(type, true, true); + const obj = { + button: 0, + which: 0, + buttons: 1, + dataTransfer: this.dataTransfer, + }; + this.copyProps(evt, e, DragDropTouch.kbdProps); + this.copyProps(evt, t, DragDropTouch.ptProps); + this.copyProps(evt, { fromTouch: true }, ['fromTouch']); // mark as from touch event + this.copyProps(evt, obj, Object.keys(obj)); + + target.dispatchEvent(evt); + return evt.defaultPrevented; + } + return false; + } + // gets an element's closest draggable ancestor + closestDraggable(e) { + for (; e; e = e.parentElement) { + if (e.hasAttribute('draggable') && e.draggable) { + return e; + } + } + return null; + } + // repeat dispatch dragover event when touch point stay still + setDragoverInterval(e) { + this.dragoverTimer = setInterval(() => { + const target = this.getTarget(e); + if (target !== this.lastTarget) { + this.dispatchEvent(e, 'dragenter', target); + this.dispatchEvent(e, 'dragleave', this.lastTarget); + this.lastTarget = target; + } + this.isDropZone = this.dispatchEvent(e, 'dragover', target); + }, DragDropTouch.DRAG_OVER_TIME); + } + clearDragoverInterval() { + if (this.dragoverTimer) { + clearInterval(this.dragoverTimer); + this.dragoverTimer = undefined; + } + } +} +/* eslint-disable-next-line @typescript-eslint/no-namespace */ +export namespace DragDropTouch { + /** + * Object used to hold the data that is being dragged during drag and drop operations. + * + * It may hold one or more data items of different types. For more information about + * drag and drop operations and data transfer objects, see + * HTML Drag and Drop API. + * + * This object is created automatically by the @see:DragDropTouch singleton and is + * accessible through the @see:dataTransfer property of all drag events. + */ + export class DataTransfer implements DataTransfer { + files; + items; + private _data; + /** + * Gets or sets the type of drag-and-drop operation currently selected. + * The value must be 'none', 'copy', 'link', or 'move'. + */ + private _dropEffect; + get dropEffect() { + return this._dropEffect; + } + set dropEffect(value) { + this._dropEffect = value; + } + /** + * Gets or sets the types of operations that are possible. + * Must be one of 'none', 'copy', 'copyLink', 'copyMove', 'link', + * 'linkMove', 'move', 'all' or 'uninitialized'. + */ + private _effectAllowed; + get effectAllowed() { + return this._effectAllowed; + } + set effectAllowed(value) { + this._effectAllowed = value; + } + /** + * Gets an array of strings giving the formats that were set in the @see:dragstart event. + */ + private _types; + get types() { + return Object.keys(this._data); + } + + constructor() { + this._dropEffect = 'move'; + this._effectAllowed = 'all'; + this._data = {}; + } + /** + * Removes the data associated with a given type. + * + * The type argument is optional. If the type is empty or not specified, the data + * associated with all types is removed. If data for the specified type does not exist, + * or the data transfer contains no data, this method will have no effect. + * + * @param type Type of data to remove. + */ + clearData(type) { + if (type !== null) { + delete this._data[type]; + } else { + this._data = null; + } + } + /** + * Retrieves the data for a given type, or an empty string if data for that type does + * not exist or the data transfer contains no data. + * + * @param type Type of data to retrieve. + */ + getData(type) { + return this._data[type] || ''; + } + + /** + * Set the data for a given type. + * + * For a list of recommended drag types, please see + * https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Recommended_Drag_Types. + * + * @param type Type of data to add. + * @param value Data to add. + */ + setData(type, value) { + this._data[type] = value; + } + /** + * Set the image to be used for dragging if a custom one is desired. + * + * @param img An image element to use as the drag feedback image. + * @param offsetX The horizontal offset within the image. + * @param offsetY The vertical offset within the image. + */ + setDragImage(img, offsetX, offsetY) { + const ddt = DragDropTouch.getInstance(); + ddt.imgCustom = img; + ddt.imgOffset = { x: offsetX, y: offsetY }; + } + } +} diff --git a/packages/devui-vue/devui/dragdrop-new/src/utils.ts b/packages/devui-vue/devui/dragdrop-new/src/utils.ts new file mode 100644 index 0000000000..328d7c85de --- /dev/null +++ b/packages/devui-vue/devui/dragdrop-new/src/utils.ts @@ -0,0 +1,168 @@ +interface ElementRef { + nativeElement?: any; +} + +export class Utils { + /** + * Polyfill for element.matches. + * See: https://developer.mozilla.org/en/docs/Web/API/Element/matches#Polyfill + * element + */ + public static matches(element: any, selectorName: string): boolean { + const proto: any = Element.prototype; + + const func = + proto['matches'] || + proto.matchesSelector || + proto.mozMatchesSelector || + proto.msMatchesSelector || + proto.oMatchesSelector || + proto.webkitMatchesSelector || + function (s: string) { + const matches = (this.document || this.ownerDocument).querySelectorAll(s); + let i = matches.length; + while (--i >= 0 && matches.item(i) !== this) { + // do nothing + } + return i > -1; + }; + + return func.call(element, selectorName); + } + + /** + * Applies the specified css class on nativeElement + * elementRef + * className + */ + public static addClass(elementRef: ElementRef | any, className: string) { + if (className === undefined) { + return; + } + const e = this.getElementWithValidClassList(elementRef); + + if (e) { + e.classList.add(className); + } + } + + /** + * Removes the specified class from nativeElement + * elementRef + * className + */ + public static removeClass(elementRef: ElementRef | any, className: string) { + if (className === undefined) { + return; + } + const e = this.getElementWithValidClassList(elementRef); + + if (e) { + e.classList.remove(className); + } + } + + /** + * Gets element with valid classList + * + * elementRef + * @returns ElementRef | null + */ + private static getElementWithValidClassList(elementRef: ElementRef) { + const e = elementRef.nativeElement ? elementRef.nativeElement : elementRef; + + if (e.classList !== undefined && e.classList !== null) { + return e; + } + + return null; + } + + public static slice(args: T[], slice?: number, sliceEnd?: number): T[] { + const ret: T[] = []; + let len = args.length; + + if (len === 0) { + return ret; + } + + const start = (slice as number) < 0 ? Math.max(0, slice! + len) : slice || 0; + + if (sliceEnd !== undefined) { + len = sliceEnd < 0 ? sliceEnd + len : sliceEnd; + } + + while (len-- > start) { + ret[len - start] = args[len]; + } + return ret; + } + + // 动态添加styles + public static addElStyles(el: any, styles: any) { + if (styles instanceof Object) { + for (const s in styles) { + if (Object.prototype.hasOwnProperty.call(styles, s)) { + if (Array.isArray(styles[s])) { + // 用于支持兼容渐退 + styles[s].forEach((val: string) => { + el.style[s] = val; + }); + } else { + el.style[s] = styles[s]; + } + } + } + } + } + public static dispatchEventToUnderElement(event: DragEvent, target?: HTMLElement, eventType?: string) { + const up = target || event.target; + up.style.display = 'none'; + const { x, y } = { x: event.clientX, y: event.clientY }; + const under = document.elementFromPoint(x, y); + up.style.display = ''; + if (!under) { + return event; + } + const ev = document.createEvent('DragEvent'); + ev.initMouseEvent( + eventType || event.type, + true, + true, + window, + 0, + event.screenX, + event.screenY, + event.clientX, + event.clientY, + event.ctrlKey, + event.altKey, + event.shiftKey, + event.metaKey, + event.button, + event.relatedTarget + ); + if (ev.dataTransfer !== null) { + ev.dataTransfer.setData('text', ''); + ev.dataTransfer.effectAllowed = event.dataTransfer!.effectAllowed; + } + setTimeout(() => { + under.dispatchEvent(ev); + }, 0); + return event; + } +} + +import { getCurrentInstance, InjectionKey } from 'vue'; + +export function injectFromContext(token: InjectionKey | string, context: any): T | undefined { + return context[token as symbol | string]; +} + +export function getContext(): any { + return (getCurrentInstance() as unknown as { provides: any }).provides; +} + +export function provideToContext(token: InjectionKey | string, value: T, context: any) { + context[token as symbol | string] = value; +} diff --git a/packages/devui-vue/docs/components/dragdrop-new/index.md b/packages/devui-vue/docs/components/dragdrop-new/index.md new file mode 100644 index 0000000000..51df5b34e4 --- /dev/null +++ b/packages/devui-vue/docs/components/dragdrop-new/index.md @@ -0,0 +1,3036 @@ +# DragDrop 2.0 拖拽 + +#### 何时使用 + +拖拽组件 + +### 基本用法 + +:::demo 从一个container拖动到另外一个container,并支持排序。 + +```vue + + + + +``` + +::: + +### 多层树状拖拽 + +:::demo 排序允许拖拽到元素上,支持层级嵌套。 + +```vue + + + +``` + +::: + +### 拖拽实体元素跟随 + +:::demo 允许拖拽时候非半透明元素跟随。也可以使用appendToBody:当拖拽离开后源位置的父对象会被销毁的话,需要把克隆体附着到body上防止被销毁。 默认会通过复制样式保证克隆到body的实体的样式是正确的,但部分深度依赖DOM节点位置的样式和属性可能会失败,需要手动调整部分样式。 + +```vue + + + +``` + +::: + +### 越边交换 + +:::demo 设置switchWhileCrossEdge允许越过边缘的时候交换。注意:不可与dropOnItem一起用,dropOnItem为true的时候无效。 + +```vue + + + +``` + +::: + +### 外部放置位置:就近,前面,后面 + +:::demo 使用defaultDropPostion配置排序器之外的区域拖拽元素放下的时候默认加到列表的前面或者后面,默认为就近('closest')。 + +```vue + + + +``` + +::: + +### 拖拽滚动容器增强 + +:::demo 搭配使用dDropScrollEnhanced指令允许拖拽到边缘的时候加速滚动条向两边滚动。 + +```vue + + + +``` + +::: + +### 源占位符 + +:::demo 使用originPlaceholder显示源位置的占位符,示例为消失动画。 + +```vue + + + +``` + +::: + +### 拖拽预览 + +:::demo 允许拖拽的时候展示自定义拖拽元素样式 + +```vue + + + +``` + +::: + +### 批量拖拽 + +:::demo 使用batchDrag指令标记可以批量拖拽,请用ctrl按键和鼠标选中多个并进行拖拽 + +```vue + + + +``` + +::: + +### 二维拖拽和组合拖拽预览 + +:::demo 使用dDragDropSyncBox指令、dDragSync指令、dDropSync指令协同拖拽,实现二维拖拽;使用dDragPreview配置拖拽预览,使用d-drag-preview-clone-dom-ref 完成拖拽节点预览的克隆。 + +```vue + + + +``` + +::: + +#### dDraggable 指令 + +##### dDraggable 参数 + +| 参数 | 类型 | 默认值 | 描述 | 跳转 Demo | +| :---------------------------: | :--------------------------------------------------------------------------------------------------------------- | :------------ | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------ | +| dragData | `any` | -- | 可选,转递给 `DropEvent`事件的数据. | [基本用法](#基本用法) | +| dragScope | `string \| Array` | 'default' | 可选,限制 drop 的位置,必须匹配对应的 `dropScope` | [基本用法](#基本用法) | +| dragOverClass | `string` | -- | 可选,拖动时被拖动元素的 css | [拖拽实体元素跟随](#拖拽实体元素跟随) | +| dragHandle | `string` | -- | 可选,可拖动拖动内容的 css 选择器,只有匹配 css 选择器的元素才能响应拖动事件, 注意是css选择器,示例:`'.title, .title > *'`,`'#header'`, `'.title *:not(input)'` | | | +| dragHandleClass | `string` | 'drag-handle' | 可选, 会给可拖动内容的应用的 css 选择器命中的元素添加的 css 类名, 第一个匹配 css 选择器的会被加上该 css 类 | [基本用法](#基本用法) | +| disabled | `boolean` | false | 可选,控制当前元素是否可拖动 false 为可以,true 为不可以 | [基本用法](#基本用法) | +| enableDragFollow | `boolean` | false | 可选,是否启用实体元素跟随(可以添加更多特效,如阴影等) | [拖拽实体元素跟随](#拖拽实体元素跟随) | +| dragFollowOptions | `{appendToBody?: boolean}` | -- | 可选,用于控制实体拖拽的一些配置 | [拖拽实体元素跟随](#拖拽实体元素跟随) | +| dragFollowOptions.appendToBody | `boolean` | false | 可选,用于控制实体拖拽的克隆元素插入的位置。默认 false 会插入到源元素父元素所有子的最后,设置为 true 会附着到。见说明 1 | [拖拽实体元素跟随](#拖拽实体元素跟随) | +| originPlaceholder | `{show?: boolean; tag?: string; style?: {cssProperties: string]: string}; text?: string; removeDelay?: number;}` | -- | 可选,设置源占位符号,用于被拖拽元素原始位置占位 | [源占位符](#源占位符) | +| originPlaceholder.show | `boolean` | true | 可选,是否显示,默认 originPlaceholder 有 Input 则显示,特殊情况可以关闭 | +| originPlaceholder.tag | `string` | 'div' | 可选,使用 tag 名,默认 originPlaceholder 使用'div',特殊情况可以置换 | +| originPlaceholder.style | `Object` | -- | 可选,传 style 对象,key 为样式属性,value 为样式值 | [源占位符](#源占位符) | +| originPlaceholder.text | `string` | -- | 可选,placeholder 内的文字 | [源占位符](#源占位符) | +| originPlaceholder.removeDelay | `number` | -- | 可选,用于希望源占位符在拖拽之后的延时里再删除,方便做动画,单位为 ms 毫秒 | [源占位符](#源占位符) | + +说明 1:dragFollowOptions 的 appendToBody 的使用场景:当拖拽离开后源位置的父对象会被销毁的话,需要把克隆体附着到 body 上防止被销毁。默认会通过复制样式保证克隆到 body 的实体的样式是正确的,但部分深度依赖 DOM 节点位置的样式和属性可能会失败,需要手动调整部分样式。 + +##### dDraggable 事件 + +| 事件 | 类型 | 描述 | 跳转 Demo | +| :------------- | :------------------------ | :------------------------ | :--------------------------- | +| dragStartEvent | `EventEmitter` | 开始拖动的 DragStart 事件 | [基本用法](#基本用法) | +| dragEndEvent | `EventEmitter` | 拖动结束的 DragEnd 事件 | [基本用法](#基本用法) | +| dropEndEvent | `EventEmitter` | 放置结束的 Drop 事件 | [基本用法](#基本用法) | + +Drag DOM Events 详情: [DragEvent](https://developer.mozilla.org/en-US/docs/Web/API/DragEvent) + +#### dDraggableBatchDrag 附加指令 + +使用方法 dDraggableBatchDrag + +##### dDraggableBatchDrag 属性 + +| 名字 | 类型 | 默认值 | 描述 | 跳转 Demo | +| :---------------------------------- | :------------------------ | :----------------- | :--------------------------------------------------------------------------------------------- | :----------------------------------- | +| batchDragGroup \| batchDrag | `string` | 'default' | 可选,批量拖拽分组组名,不同组名 | +| batchDragActive | `boolean` | false | 可选,是否把元素加入到批量拖拽组. 见说明 1。 | [批量拖拽](#批量拖拽) | +| batchDragLastOneAutoActiveEventKeys | `Array` | ['ctrlKey'] | 可选,通过过拖拽可以激活批量选中的拖拽事件判断。见说明 2。 | +| batchDragStyle | `Array` | ['badge', 'stack'] | 可选,批量拖拽的效果,badge 代表右上角有统计数字,stack 代表有堆叠效果,数组里有该字符串则有效 | [批量拖拽](#批量拖拽) | + +说明 1: `batchDragActive`为`true`的时候会把元素加入组里,加入顺序为变为 true 的顺序,先加入的在数组前面。第一个元素会确认批量的组名,如果后加入的组名和先加入的组名不一致,则后者无法加入。 +说明 2: `batchDragLastOneAutoActiveEventKeys`的默认值为['ctrlKey'], 即可以通过按住 ctrl 键拖动最后一个元素, 该元素自动加入批量拖拽的组,判断条件是 dragStart 事件里的 ctrlKey 事件为 true。目前仅支持判断 true/false。该参数为数组,可以判断任意一个属性值为 true 则生效,可用于不同操作系统的按键申明。 + +##### dDraggableBatchDrag事件 + +| 名字 | 类型 | 描述 | 跳转 Demo | +| :------------------- | :--------------------------------------- | :------------------------------------------------- | :----------------------------------- | +| batchDragActiveEvent | `EventEmitter<{el: Element, data: any}>` | 通过拖拽把元素加入了批量拖拽组,通知外部选中该元素 | [批量拖拽](#批量拖拽) | + +#### dDroppable 指令 + +##### dDroppable 参数 + +| 参数 | 类型 | 默认值 | 描述 | 跳转 Demo | +| :-------------------------- | :--------------------------------------------- | :------------------------------------------ | :----------------------------------------------------------------------------------------------------------------------------------------------------- | :----------------------------------------- | +| dropScope | `string \| Array` | 'default' | 可选,限制 drop 的区域,对应 dragScope | [基本用法](#基本用法) | +| dragOverClass | `string` | -- | 可选,dragover 时 drop 元素上应用的 css | +| placeholderStyle | `Object` | {backgroundColor: '#6A98E3', opacity: '.4'} | 可选,允许 sort 时,用于占位显示 | [源占位符](#源占位符) | +| placeholderText | `string` | '' | 可选,允许 sort 时,用于占位显示内部的文字 | +| allowDropOnItem | `boolean` | false | 可选,允许 sort 时,用于允许拖动到元素上,方便树形结构的拖动可以成为元素的子节点 | [多层树状拖拽](#多层树状拖拽) | +| dragOverItemClass | `string` | -- | 可选,`allowDropOnItem`为`true`时,才有效,用于允许拖动到元素上后,被命中的元素增加样式 | [多层树状拖拽](#多层树状拖拽) | +| nestingTargetRect | `{height?: number, width?: number}` | -- | 可选,用于修正有内嵌列表后,父项高度被撑大,此处 height,width 为父项自己的高度(用于纵向拖动),宽度(用于横向拖动) | [多层树状拖拽](#多层树状拖拽) | +| defaultDropPosition | `'closest' \| 'before' \| 'after'` | 'closest' | 可选,设置拖拽到可放置区域但不在列表区域的放置位置,`'closest'` 为就近放下, `'before'`为加到列表头部, `'after'`为加到列表尾部 | [外部放置位置](#外部放置位置:就近,前面,后面) | +| dropSortCountSelector | `string` | -- | 可选,带有 sortable 的容器的情况下排序,计数的内容的选择器名称,可以用于过滤掉不应该被计数的元素 | +| dropSortVirtualScrollOption | `{totalLength?: number; startIndex?: number;}` | -- | 可选,用于虚拟滚动列表中返回正确的 dropIndex 需要接收 totalLength 为列表的真实总长度, startIndex 为当前排序区域显示的第一个 dom 的在列表内的 index 值 | +| switchWhileCrossEdge | `boolean` | false | 可选,是否启用越过立即交换位置的算法, 不能与allowDropOnItem一起用,allowDropOnItem为true时,此规则无效 | +| placeholderTag | `string` | 'div' | 可选,占位显示的元素标签 | + +##### dDroppable 事件 + +| 事件 | 类型 | 描述 | 跳转 Demo | +| :------------- | :------------------------------------------ | :------------------------------------------------------------------------------ | :--------------------------- | +| dragEnterEvent | `EventEmitter` | drag 元素进入的 dragenter 事件 | [基本用法](#基本用法) | +| dragOverEvent | `EventEmitter` | drag 元素在 drop 区域上的 dragover 事件 | [基本用法](#基本用法) | +| dragLeaveEvent | `EventEmitter` | drag 元素离开的 dragleave 事件 | [基本用法](#基本用法) | +| dropEvent | `EventEmitter<`[`DropEvent`](#dropevent)`>` | 放置一个元素, 接收的事件,其中 nativeEvent 表示原生的 drop 事件,其他见定义注释 | [基本用法](#基本用法) | + +##### DropEvent + +```typescript +type DropEvent = { + nativeEvent: any; // 原生的drop事件 + dragData: any; // drag元素的dragData数据 + dropSubject: Subject; //drop事件的Subject + dropIndex?: number; // drop的位置在列表的index + dragFromIndex?: number; // drag元素在原来的列表的index,注意使用虚拟滚动数据无效 + dropOnItem?: boolean; // 是否drop到了元素的上面,搭配allowDropOnItem使用 +} +``` + +#### dSortable 指令 + +指定需要参与排序的 Dom 父容器(因为 drop 只是限定可拖拽区域,具体渲染由使用者控制) + +##### dSortable 参数 + +| 名字 | 类型 | 默认值 | 描述 | 跳转 Demo | +| :------------- | :----------- | :----- | :------------------------------ | :-------- | +| dSortDirection | `'v' \| 'h'` | 'v' | 'v'垂直排序,'h'水平排序 | +| dSortableZMode | `boolean` | false | 是否是 z 模式折回排序,见说明 1 | + +说明 1: z 自行排序最后是以大方向为准的,如果从左到右排遇到行末换行,需要使用的垂直排序+z 模式,因为最后数据是从上到下的只是局部的数据是从左到右。 + +##### dDropScrollEnhanced 参数 + +| 名字 | 类型 | 默认值 | 描述 | 跳转 Demo | +| :----------------- | :---------------------------------------------------------------------------------------------- | :------- | :------------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------- | +| direction | [`DropScrollDirection`](#dropscrolldirection)即`'v'\|'h'` | 'v' | 滚动方向,垂直滚动`'v'`, 水平滚动 `'h'` | [拖拽滚动容器增强](#拖拽滚动容器增强) | +| responseEdgeWidth | `string \| ((total: number) => string)` | '100px' | 响应自动滚动边缘宽度, 函数的情况传入的为列表容器同个方向相对宽度 | [拖拽滚动容器增强](#拖拽滚动容器增强) | +| speedFn | [`DropScrollSpeedFunction`](#dropscrolldirection) | 内置函数 | 速率函数,见备注 | +| minSpeed | `DropScrollSpeed`即`number` | 50 | 响应最小速度 ,函数计算小于这个速度的时候,以最小速度为准 | +| maxSpeed | `DropScrollSpeed`即`number` | 1000 | 响应最大速度 ,函数计算大于这个速度的时候,以最大速度为准 | +| viewOffset | {forward?: [`DropScrollAreaOffset`](#dropscrollareaoffset); backward?: `DropScrollAreaOffset`;} | -- | 设置拖拽区域的偏移,用于某些位置修正 | +| dropScrollScope | `string\| Array` | -- | 允许触发滚动 scope,不配置为默认接收所有 scope,配置情况下,draggable 的`dragScope`和`dropScrollScope`匹配得上才能触发滚动 | [拖拽滚动容器增强](#拖拽滚动容器增强) | +| backSpaceDroppable | `boolean` | true | 是否允许在滚动面板上同时触发放置到滚动面板的下边的具体可以放置元素,默认为 true,设置为 false 则不能边滚动边放置 | + +备注: speedFn 默认函数为`(x: number) => Math.ceil((1 - x) * 18) * 100`,传入数字`x`是 鼠标位置距离边缘的距离占全响应宽度的百分比, +最终速度将会是 speedFn(x),但不会小于最小速度`minSpeed`或者大于最大速度`maxSpeed`。 + +相关类型定义: + +###### DropScrollDirection + +```typescript +export type DropScrollDirection = 'h' | 'v'; +``` + +###### DropScrollSpeed + +```typescript +export type DropScrollEdgeDistancePercent = number; // unit: 1 +export type DropScrollSpeed = number; // Unit: px/s +export type DropScrollSpeedFunction = (x: DropScrollEdgeDistancePercent) => DropScrollSpeed; +``` + +###### DropScrollAreaOffset + +```typescript +export type DropScrollAreaOffset = { + left?: number; + right?: number; + top?: number; + bottom?: number; + widthOffset?: number; + heightOffset?: number; +}; + +export enum DropScrollOrientation { + forward, // Forward, right/bottom + backward, // Backward, left/up +} +export type DropScrollTriggerEdge = 'left' | 'right' | 'top' | 'bottom'; +``` + +`DropScrollAreaOffset` 仅重要和次要定位边有效, forward 代表后右或者往下滚动,backward 表示往左或者往上滚动 + +| direction | `v` 上下滚动 | `h` 左右滚动 | +| :------------------ | :--------------- | :------------- | +| forward 往下或往右 | `left` ,`bottom` | `top` ,`right` | +| backward 往左或网上 | `left`,`top` | `top`,`left` | + +##### dDropScrollEnhancedSide 附属指令 + +如果需要同时两个方向都有滚动条,则需要使用 dDropScrollEnhanced 的同时使用 dDropScrollEnhancedSide,参数列表同 dDropScrollEnhanced 指令,唯一不同是 direction,如果为`'v'`则 side 附属指令的实际方向为`'h'`。 + +| 名字 | 类型 | 默认值 | 描述 | +| :----------------- | :--------------------------------------------------------------------- | :------- | :------------------------------------------------------------------------------------------------------------------------- | +| direction | `DropScrollSpeed`即`'v'\|'h'` | 'v' | 滚动方向,垂直滚动`'v'`, 水平滚动 `'h'` | +| responseEdgeWidth | `string \| ((total: number) => string)` | '100px' | 响应自动滚动边缘宽度, 函数的情况传入的为列表容器同个方向相对宽度 | +| speedFn | `DropScrollSpeedFunction` | 内置函数 | 速率函数,见备注 | +| minSpeed | `DropScrollSpeed`即`number` | 50 | 响应最小速度 ,函数计算小于这个速度的时候,以最小速度为准 | +| maxSpeed | `DropScrollSpeed`即`number` | 1000 | 响应最大速度 ,函数计算大于这个速度的时候,以最大速度为准 | +| viewOffset | {forward?: `DropScrollAreaOffset`; backward?: `DropScrollAreaOffset`;} | -- | 设置拖拽区域的偏移,用于某些位置修正 | +| dropScrollScope | `string\| Array` | -- | 允许触发滚动 scope,不配置为默认接收所有 scope,配置情况下,draggable 的`dragScope`和`dropScrollScope`匹配得上才能触发滚动 | +| backSpaceDroppable | `boolean` | true | 是否允许在滚动面板上同时触发放置到滚动面板的下边的具体可以放置元素,默认为 true,设置为 false 则不能边滚动边放置 | + +#### 使用 `dDraggable` & `dDroppable` 指令 + +```html +
      +
    • Coffee
    • +
    • Tea
    • +
    • Milk
    • +
    +``` + +```html +
    +

    Drop items here

    +
    +``` + +#### CSS + +`dDraggable` & `dDroppable` 指令都有`[dragOverClass]`作为输入. + 提供 drag 和 drop 时的 hover 样式,注意是`字符串` + +```html +
    +

    Drop items here

    +
    +``` + +#### 限制 Drop 区域 + +用[dragScope]和[dropScope]限制拖动区域,可以是字符串或数组,只有 drag 和 drop 的区域对应上才能放进去 + +```html +
      +
    • Coffee
    • +
    • Tea
    • +
    • Biryani
    • +
    • Kebab
    • + ... +
    +``` + +```html +
    +

    只有 Drinks 可以放在这个container里

    +
    + +
    +

    Meal 和 Drinks 可以放在这个container里

    +
    +``` + +#### 传递数据 + +`dDraggable`可以用`dragData`向`dDroppable`传递数据 +`dDroppable`用`@dropEvent`事件接收数据 + +```html +
      +
    • {{item.name}}
    • +
    + +
    +
    Drop Items here
    +
    +
  • {{item.name}}
  • +
    +
    +``` + +```js +setup() { + const items = [ + { name: 'Apple', type: 'fruit' }, + { name: 'Carrot', type: 'vegetable' }, + { name: 'Orange', type: 'fruit' }, + ]; + const droppedItems = []; + + onItemDrop(e) { + // Get the dropped data here + droppedItems.push(e.dragData); + } +} +``` + +###### Drag Handle + +Drag 句柄可以指定实际响应 draggable 事件的元素,而不是 draggable 本身 +这个参数必须是一个字符串,实际上是一个 css 选择器 + +```html +
  • + 只有.drag-handle可以响应拖动事件来拖起li +
    +
  • +``` + +###### 异步 DropEnd,通知 Drag 元素 + +`dDraggable`有一个`dropEndEvent`事件,此事件非浏览器默认事件而是自定义事件,非组件自动触发触发方式是在`dDroppable`的`dropEvent`事件的参数中有一个 dropSubject,当需要触发 drag 元素上的 dropEndEvent 事件的时候调用 dropSubject.next(params) 一般是在接口返回之后 例如: + +```html +
      +
    • {{item.name}}
    • +
    + +
    +
    Drop Items here
    +
    +
  • {{item.name}}
  • +
    +
    +``` + +```js +setup() { + onItemDrop(e) { + ajax.onSuccess(() => { + e.dropSubject.next(params); //此时才触发dragComponent的dropEnd 并且params对应onDropEnd的$event; + }); + } + onDropEnd(event, i) {} +} +``` + +#### 协同拖拽, 用于二维拖拽,跨纬度拖拽场景 + +##### 协同拖 dDragSync + +用于 dDraggle 对象和同时会被拖走的对象。 + +###### dDragSync 参数 + +| 参数 | 类型 | 默认值 | 描述 | 跳转 Demo | +| :-------- | :------- | :----- | :--------------------------------------------------------------- | :-------------------------------------------------- | +| dDragSync | `string` | '' | 必选,拖同步的组名,为空或者空字符串的时候无效,不与其他内容同步 | [二维拖拽和组合拖拽预览](#二维拖拽和组合拖拽预览) | + +##### 协同放 dDropSortSync + +用于 dDroppable 对象和与 droppable 内 sortable 结构相同的 sortable 区域, 注意 dDroppable 对象里是与 dDroppable 对象同个对象上注册 dDropSortSync,其他不带 dDroppable 的与放置在排序区域。 + +###### dDropSortSync 参数 + +| 参数 | 类型 | 默认值 | 描述 | 跳转 Demo | +| :----------------- | :---------- | :----- | :--------------------------------------------------------------- | :-------------------------------------------------- | +| dDropSortSync | `string` | '' | 必选,放同步的组名,为空或者空字符串的时候无效,不与其他内容同步 | [二维拖拽和组合拖拽预览](#二维拖拽和组合拖拽预览) | +| dDropSyncDirection | `'v'\| 'h'` | 'v' | 可选,与 dSortable 的方向正交 | + +##### 协同监听盒子 dDragDropSyncBox + +用于统计 dDragSync 和 dDropSortSync 的公共父祖先。 +无参数,放置在公共统计区域则可。 + +#### 拖拽预览, 用于需要替换拖拽预览的场景 + +##### 拖拽预览 dDragPreview + +需要和 dDraggable 搭配使用, 用于拖起的时候拖动对象的模板 + +###### dDragPreview 参数 + +| 参数 | 类型 | 默认值 | 描述 | 跳转 Demo | +| :---------------------------------- | :------------------------------ | :----- | :--------------------------------------------------------------------------------- | :-------------------------------------------------- | +| dDragPreview | `TemplateRef` | -- | 必选,预览的模板引用 | [二维拖拽和组合拖拽预览](#二维拖拽和组合拖拽预览) | +| dragPreviewData | `any` | -- | 可选,自定义数据,将由模板变量获得 | +| dragPreviewOptions | `{ skipBatchPreview : boolean}` | -- | 可选,预览选项 | +| dragPreviewOptions.skipBatchPreview | `boolean` | false | 可选,预览选项, 是否跳过批量预览的样式处理。建议自行处理批量拖拽预览模板的可以跳过 | + +###### dDragPreview 模板可用变量 + +| 变量 | 类型 | 变量含义说明 | +| :-----------------: | :------------------: | :-----------------------------------------------------------------------------------------: | +| data | `any` | 从拖拽预览传入的 dragPreviewData 数据 | +| draggedEl | `HTMLElement` | 被拖拽的 DOM 元素 | +| dragData | `any` | 被拖拽元素携带的 dragData 数据 | +| batchDragData | `Array` | 被批量拖拽的对象的 dragData 数据的数组, 含被拖拽元素的 dragData, 并且 dragData 处于第一位 | +| dragSyncDOMElements | `Array` | 被协同拖拽的 DOM 元素, 不包括 draggedEl 指向的 DOM 元素 | + +##### 拖拽预览辅助克隆节点 组件`` + +可以从节点的引用中恢复 DOM 的克隆对象作为预览 + +| 参数 | 类型 | 默认值 | 描述 | 跳转 Demo | +| :-------- | :------------ | :----- | :----------------------------------------- | :-------- | +| domRef | `HTMLElement` | -- | 必选,否则无意义,克隆节点的 DOM 引用 | [二维拖拽和组合拖拽预览](#二维拖拽和组合拖拽预览)| +| copyStyle | `boolean` | true | 可选,是否克隆节点的时候对节点依次克隆样式 |[二维拖拽和组合拖拽预览](#二维拖拽和组合拖拽预览)| diff --git a/packages/devui-vue/package.json b/packages/devui-vue/package.json index f588bb4631..582e89fd70 100644 --- a/packages/devui-vue/package.json +++ b/packages/devui-vue/package.json @@ -1,6 +1,6 @@ { "name": "vue-devui", - "version": "1.6.3-select.0", + "version": "1.6.4", "license": "MIT", "description": "DevUI components based on Vite and Vue3", "keywords": [ @@ -42,7 +42,7 @@ "devui-cli": "devui" }, "peerDependencies": { - "vue": "^3.2" + "vue": "^3.3" }, "dependencies": { "@babel/helper-hoist-variables": "^7.22.5", @@ -71,8 +71,9 @@ "mermaid": "9.1.1", "mitt": "^3.0.0", "monaco-editor": "0.34.0", + "rxjs": "^7.8.1", + "vue": "^3.3.4", "uuid": "^9.0.1", - "vue": "^3.2.37", "vue-router": "^4.0.3", "xss": "^1.0.14" }, @@ -125,4 +126,4 @@ "vitepress-theme-demoblock": "1.3.2", "vue-tsc": "0.38.8" } -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 84aedc4140..7b6e84f1f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -165,6 +165,9 @@ importers: '@floating-ui/dom': specifier: ^0.4.4 version: 0.4.4 + '@iktakahiro/markdown-it-katex': + specifier: ^4.0.1 + version: 4.0.1 '@types/codemirror': specifier: 0.0.97 version: 0.0.97 @@ -176,7 +179,7 @@ importers: version: 3.2.33 '@vueuse/core': specifier: 8.9.4 - version: 8.9.4(vue@3.2.37) + version: 8.9.4(vue@3.3.4) async-validator: specifier: ^4.0.7 version: 4.0.7 @@ -205,8 +208,11 @@ importers: specifier: ^10.0.0 version: 10.0.0 highlight.js: - specifier: 10.7.3 - version: 10.7.3 + specifier: ^11.6.0 + version: 11.6.0 + katex: + specifier: ^0.12.0 + version: 0.12.0 lodash: specifier: ^4.17.21 version: 4.17.21 @@ -216,6 +222,9 @@ importers: markdown-it: specifier: 12.2.0 version: 12.2.0 + markdown-it-plantuml: + specifier: ^1.4.1 + version: 1.4.1 mermaid: specifier: 9.1.1 version: 9.1.1 @@ -225,12 +234,18 @@ importers: monaco-editor: specifier: 0.34.0 version: 0.34.0 + rxjs: + specifier: ^7.8.1 + version: 7.8.1 + uuid: + specifier: ^9.0.1 + version: 9.0.1 vue: - specifier: ^3.2.37 - version: 3.2.37 + specifier: ^3.3.4 + version: 3.3.4 vue-router: specifier: ^4.0.3 - version: 4.0.12(vue@3.2.37) + version: 4.0.12(vue@3.3.4) xss: specifier: ^1.0.14 version: 1.0.14 @@ -297,7 +312,7 @@ importers: version: 3.2.31 '@vue/test-utils': specifier: ^2.0.0-rc.9 - version: 2.0.0-rc.17(vue@3.2.37) + version: 2.0.0-rc.17(vue@3.3.4) '@vuedx/typecheck': specifier: ^0.4.1 version: 0.4.1 @@ -374,84 +389,6 @@ importers: specifier: 0.38.8 version: 0.38.8(typescript@4.5.5) - packages/devui-vue/build: - dependencies: - '@devui-design/icons': - specifier: ^1.3.0 - version: 1.3.0 - '@floating-ui/dom': - specifier: ^0.4.4 - version: 0.4.4 - '@types/codemirror': - specifier: 0.0.97 - version: 0.0.97 - '@types/lodash-es': - specifier: ^4.17.4 - version: 4.17.6 - '@vue/shared': - specifier: ^3.2.33 - version: 3.2.33 - '@vueuse/core': - specifier: 8.9.4 - version: 8.9.4(vue@3.2.37) - async-validator: - specifier: ^4.0.7 - version: 4.0.7 - clipboard: - specifier: ^2.0.11 - version: 2.0.11 - clipboard-copy: - specifier: ^4.0.1 - version: 4.0.1 - codemirror: - specifier: 5.63.3 - version: 5.63.3 - dayjs: - specifier: ^1.11.3 - version: 1.11.3 - devui-theme: - specifier: ^0.0.1 - version: link:../../devui-theme - diff2html: - specifier: ^3.4.35 - version: 3.4.35 - echarts: - specifier: 5.3.3 - version: 5.3.3 - fs-extra: - specifier: ^10.0.0 - version: 10.0.0 - highlight.js: - specifier: 10.7.3 - version: 10.7.3 - lodash: - specifier: ^4.17.21 - version: 4.17.21 - lodash-es: - specifier: ^4.17.20 - version: 4.17.21 - markdown-it: - specifier: 12.2.0 - version: 12.2.0 - mermaid: - specifier: 9.1.1 - version: 9.1.1 - mitt: - specifier: ^3.0.0 - version: 3.0.0 - monaco-editor: - specifier: 0.34.0 - version: 0.34.0 - vue: - specifier: ^3.2 - version: 3.2.37 - vue-router: - specifier: ^4.0.3 - version: 4.0.12(vue@3.2.37) - xss: - specifier: ^1.0.14 - version: 1.0.14 - packages/devui-vue/build/action-timeline: {} packages/devui-vue/build/alert: {} @@ -482,6 +419,8 @@ importers: packages/devui-vue/build/date-picker-pro: {} + packages/devui-vue/build/dragdrop: {} + packages/devui-vue/build/drawer: {} packages/devui-vue/build/dropdown: {} @@ -494,7 +433,7 @@ importers: packages/devui-vue/build/form: {} - packages/devui-vue/build/fullscreen: {} + packages/devui-vue/build/git-graph: {} packages/devui-vue/build/grid: {} @@ -755,7 +694,7 @@ packages: resolution: {integrity: sha512-s6t2w/IPQVTAET1HitoowRGXooX8mCgtuP5195wD/QJPV6wYjpujCGF7JuMODVX2ZAJOf1GT6DT9MHEZvLOFSw==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.17.0 + '@babel/types': 7.22.5 dev: true /@babel/helper-builder-binary-assignment-operator-visitor@7.16.7: @@ -763,7 +702,7 @@ packages: engines: {node: '>=6.9.0'} dependencies: '@babel/helper-explode-assignable-expression': 7.16.7 - '@babel/types': 7.17.0 + '@babel/types': 7.22.5 dev: true /@babel/helper-compilation-targets@7.16.7(@babel/core@7.17.5): @@ -837,7 +776,7 @@ packages: resolution: {integrity: sha512-KyUenhWMC8VrxzkGP0Jizjo4/Zx+1nNZhgocs+gLzyZyB8SHidhoq9KK/8Ato4anhwsivfkBLftky7gvzbZMtQ==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.17.0 + '@babel/types': 7.22.5 dev: true /@babel/helper-function-name@7.16.7: @@ -853,7 +792,7 @@ packages: resolution: {integrity: sha512-flc+RLSOBXzNzVhcLu6ujeHUrD6tANAOU5ojrRx/as+tbzf8+stUCj7+IfRRoAbEZqj/ahXEMsjhOhgeZsrnTw==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.17.0 + '@babel/types': 7.22.5 dev: true /@babel/helper-hoist-variables@7.22.5: @@ -866,7 +805,7 @@ packages: resolution: {integrity: sha512-VtJ/65tYiU/6AbMTDwyoXGPKHgTsfRarivm+YbB5uAzKUyuPjgZSgAFeG87FCigc7KNHu2Pegh1XIT3lXjvz3Q==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.17.0 + '@babel/types': 7.22.5 dev: true /@babel/helper-module-imports@7.16.7: @@ -884,10 +823,10 @@ packages: '@babel/helper-module-imports': 7.16.7 '@babel/helper-simple-access': 7.16.7 '@babel/helper-split-export-declaration': 7.16.7 - '@babel/helper-validator-identifier': 7.16.7 + '@babel/helper-validator-identifier': 7.22.5 '@babel/template': 7.16.7 '@babel/traverse': 7.17.3 - '@babel/types': 7.17.0 + '@babel/types': 7.22.5 transitivePeerDependencies: - supports-color dev: true @@ -896,7 +835,7 @@ packages: resolution: {integrity: sha512-EtgBhg7rd/JcnpZFXpBy0ze1YRfdm7BnBX4uKMBd3ixa3RGAE002JZB66FJyNH7g0F38U05pXmA5P8cBh7z+1w==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.17.0 + '@babel/types': 7.22.5 dev: true /@babel/helper-plugin-utils@7.16.7: @@ -910,7 +849,7 @@ packages: dependencies: '@babel/helper-annotate-as-pure': 7.16.7 '@babel/helper-wrap-function': 7.16.8 - '@babel/types': 7.17.0 + '@babel/types': 7.22.5 transitivePeerDependencies: - supports-color dev: true @@ -923,7 +862,7 @@ packages: '@babel/helper-member-expression-to-functions': 7.16.7 '@babel/helper-optimise-call-expression': 7.16.7 '@babel/traverse': 7.17.3 - '@babel/types': 7.17.0 + '@babel/types': 7.22.5 transitivePeerDependencies: - supports-color dev: true @@ -932,14 +871,14 @@ packages: resolution: {integrity: sha512-ZIzHVyoeLMvXMN/vok/a4LWRy8G2v205mNP0XOuf9XRLyX5/u9CnVulUtDgUTama3lT+bf/UqucuZjqiGuTS1g==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.17.0 + '@babel/types': 7.22.5 dev: true /@babel/helper-skip-transparent-expression-wrappers@7.16.0: resolution: {integrity: sha512-+il1gTy0oHwUsBQZyJvukbB4vPMdcYBrFHa0Uc4AizLxbq6BOYC51Rv4tWocX9BLBDLZ4kc6qUFpQ6HRgL+3zw==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.17.0 + '@babel/types': 7.22.5 dev: true /@babel/helper-split-export-declaration@7.16.7: @@ -973,7 +912,7 @@ packages: '@babel/helper-function-name': 7.16.7 '@babel/template': 7.16.7 '@babel/traverse': 7.17.3 - '@babel/types': 7.17.0 + '@babel/types': 7.22.5 transitivePeerDependencies: - supports-color dev: true @@ -984,7 +923,7 @@ packages: dependencies: '@babel/template': 7.16.7 '@babel/traverse': 7.17.3 - '@babel/types': 7.17.0 + '@babel/types': 7.22.5 transitivePeerDependencies: - supports-color dev: true @@ -993,7 +932,7 @@ packages: resolution: {integrity: sha512-5FnTQLSLswEj6IkgVw5KusNUUFY9ZGqe/TRFnP/BKYHYgfh7tc+C7mwiy95/yNP7Dh9x580Vv8r7u7ZfTBFxdw==} engines: {node: '>=6.9.0'} dependencies: - '@babel/helper-validator-identifier': 7.16.7 + '@babel/helper-validator-identifier': 7.22.5 chalk: 2.4.2 js-tokens: 4.0.0 dev: true @@ -1003,7 +942,7 @@ packages: engines: {node: '>=6.0.0'} hasBin: true dependencies: - '@babel/types': 7.17.0 + '@babel/types': 7.22.5 dev: true /@babel/parser@7.17.3: @@ -1013,6 +952,13 @@ packages: dependencies: '@babel/types': 7.17.0 + /@babel/parser@7.24.1: + resolution: {integrity: sha512-Zo9c7N3xdOIQrNip7Lc9wvRPzlRtovHVE4lkz8WEDr7uYh/GMQhSiIgFxGIArRHYdJE5kxtZjAf8rT0xhdLCzg==} + engines: {node: '>=6.0.0'} + hasBin: true + dependencies: + '@babel/types': 7.22.5 + /@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.16.7(@babel/core@7.17.5): resolution: {integrity: sha512-anv/DObl7waiGEnC24O9zqL0pSuI9hljihqiDuFHC8d7/bjr/4RLGPWuc8rYOff/QPzbEPSkzG8wGG9aDuhHRg==} engines: {node: '>=6.9.0'} @@ -1895,7 +1841,7 @@ packages: '@babel/helper-function-name': 7.16.7 '@babel/helper-split-export-declaration': 7.16.7 '@babel/parser': 7.17.3 - '@babel/types': 7.17.0 + '@babel/types': 7.22.5 debug: 4.3.3(supports-color@8.1.1) globals: 11.12.0 lodash: 4.17.21 @@ -1924,7 +1870,7 @@ packages: /@babel/types@7.12.1: resolution: {integrity: sha512-BzSY3NJBKM4kyatSOWh3D/JJ2O3CVzBybHWxtgxnggaxEuaSTTDqeiSb/xk9lrkw2Tbqyivw5ZU4rT+EfznQsA==} dependencies: - '@babel/helper-validator-identifier': 7.16.7 + '@babel/helper-validator-identifier': 7.22.5 lodash: 4.17.21 to-fast-properties: 2.0.0 dev: true @@ -2239,6 +2185,12 @@ packages: engines: {node: '>=6.9.0'} dev: true + /@iktakahiro/markdown-it-katex@4.0.1: + resolution: {integrity: sha512-kGFooO7fIOgY34PSG8ZNVsUlKhhNoqhzW2kq94TNGa8COzh73PO4KsEoPOsQVG1mEAe8tg7GqG0FoVao0aMHaw==} + dependencies: + katex: 0.12.0 + dev: false + /@intlify/core-base@9.1.9: resolution: {integrity: sha512-x5T0p/Ja0S8hs5xs+ImKyYckVkL4CzcEXykVYYV6rcbXxJTe2o58IquSqX9bdncVKbRZP7GlBU1EcRaQEEJ+vw==} engines: {node: '>= 10'} @@ -2529,6 +2481,9 @@ packages: resolution: {integrity: sha512-Fg32GrJo61m+VqYSdRSjRXMjQ06j8YIYfcTqndLYVAaHmroZHLJZCydsWBOTDqXS2v+mjxohBWEMfg97GXmYQg==} dev: true + /@jridgewell/sourcemap-codec@1.4.15: + resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} + /@jridgewell/trace-mapping@0.3.4: resolution: {integrity: sha512-vFv9ttIedivx0ux3QSjhgtCVjPZd5l46ZOMDSCwnH1yUO2e964gO8LZGyv2QkqcgR6TnBU1v+1IFqmeoG+0UJQ==} dependencies: @@ -2643,20 +2598,20 @@ packages: /@types/babel__generator@7.6.4: resolution: {integrity: sha512-tFkciB9j2K755yrTALxD44McOrk+gfpIpvC3sxHjRawj6PfnQxrse4Clq5y/Rq+G3mrBurMax/lG8Qn2t9mSsg==} dependencies: - '@babel/types': 7.17.0 + '@babel/types': 7.22.5 dev: true /@types/babel__template@7.4.1: resolution: {integrity: sha512-azBFKemX6kMg5Io+/rdGT0dkGreboUVR0Cdm3fz9QJWpaQGJRQXl7C+6hOTCZcMll7KFyEQpgbYI2lHdsS4U7g==} dependencies: '@babel/parser': 7.17.3 - '@babel/types': 7.17.0 + '@babel/types': 7.22.5 dev: true /@types/babel__traverse@7.14.2: resolution: {integrity: sha512-K2waXdXBi2302XUdcHcR1jCeU0LL4TD9HRs/gk0N2Xvrht+G/BfJa4QObBQZfhMdxiCpV3COl5Nfq4uKTeTnJA==} dependencies: - '@babel/types': 7.17.0 + '@babel/types': 7.22.5 dev: true /@types/braces@3.0.1: @@ -3102,6 +3057,15 @@ packages: '@vue/shared': 3.2.37 estree-walker: 2.0.2 source-map: 0.6.1 + dev: true + + /@vue/compiler-core@3.3.4: + resolution: {integrity: sha512-cquyDNvZ6jTbf/+x+AgM2Arrp6G4Dzbb0R64jiG804HRMfRiFXWI6kqUVqZ6ZR0bQhIoQjB4+2bhNtVwndW15g==} + dependencies: + '@babel/parser': 7.24.1 + '@vue/shared': 3.3.4 + estree-walker: 2.0.2 + source-map-js: 1.0.2 /@vue/compiler-dom@3.2.31: resolution: {integrity: sha512-60zIlFfzIDf3u91cqfqy9KhCKIJgPeqxgveH2L+87RcGU/alT6BRrk5JtUso0OibH3O7NXuNOQ0cDc9beT0wrg==} @@ -3114,6 +3078,13 @@ packages: dependencies: '@vue/compiler-core': 3.2.37 '@vue/shared': 3.2.37 + dev: true + + /@vue/compiler-dom@3.3.4: + resolution: {integrity: sha512-wyM+OjOVpuUukIq6p5+nwHYtj9cFroz9cwkfmP9O1nzH68BenTTv0u7/ndggT8cIQlnBeOo6sUT/gvHcIkLA5w==} + dependencies: + '@vue/compiler-core': 3.3.4 + '@vue/shared': 3.3.4 /@vue/compiler-sfc@3.2.31: resolution: {integrity: sha512-748adc9msSPGzXgibHiO6T7RWgfnDcVQD+VVwYgSsyyY8Ans64tALHZANrKtOzvkwznV/F4H7OAod/jIlp/dkQ==} @@ -3142,6 +3113,21 @@ packages: magic-string: 0.25.7 postcss: 8.4.6 source-map: 0.6.1 + dev: true + + /@vue/compiler-sfc@3.3.4: + resolution: {integrity: sha512-6y/d8uw+5TkCuzBkgLS0v3lSM3hJDntFEiUORM11pQ/hKvkhSKZrXW6i69UyXlJQisJxuUEJKAWEqWbWsLeNKQ==} + dependencies: + '@babel/parser': 7.24.1 + '@vue/compiler-core': 3.3.4 + '@vue/compiler-dom': 3.3.4 + '@vue/compiler-ssr': 3.3.4 + '@vue/reactivity-transform': 3.3.4 + '@vue/shared': 3.3.4 + estree-walker: 2.0.2 + magic-string: 0.30.8 + postcss: 8.4.6 + source-map-js: 1.0.2 /@vue/compiler-ssr@3.2.31: resolution: {integrity: sha512-mjN0rqig+A8TVDnsGPYJM5dpbjlXeHUm2oZHZwGyMYiGT/F4fhJf/cXy8QpjnLQK4Y9Et4GWzHn9PS8AHUnSkw==} @@ -3154,6 +3140,13 @@ packages: dependencies: '@vue/compiler-dom': 3.2.37 '@vue/shared': 3.2.37 + dev: true + + /@vue/compiler-ssr@3.3.4: + resolution: {integrity: sha512-m0v6oKpup2nMSehwA6Uuu+j+wEwcy7QmwMkVNVfrV9P2qE5KshC6RwOCq8fjGS/Eak/uNb8AaWekfiXxbBB6gQ==} + dependencies: + '@vue/compiler-dom': 3.3.4 + '@vue/shared': 3.3.4 /@vue/devtools-api@6.0.12: resolution: {integrity: sha512-iO/4FIezHKXhiDBdKySCvJVh8/mZPxHpiQrTy+PXVqJZgpTPTdHy4q8GXulaY+UKEagdkBb0onxNQZ0LNiqVhw==} @@ -3176,6 +3169,16 @@ packages: '@vue/shared': 3.2.37 estree-walker: 2.0.2 magic-string: 0.25.7 + dev: true + + /@vue/reactivity-transform@3.3.4: + resolution: {integrity: sha512-MXgwjako4nu5WFLAjpBnCj/ieqcjE2aJBINUNQzkZQfzIZA4xn+0fV1tIYBJvvva3N3OvKGofRLvQIwEQPpaXw==} + dependencies: + '@babel/parser': 7.24.1 + '@vue/compiler-core': 3.3.4 + '@vue/shared': 3.3.4 + estree-walker: 2.0.2 + magic-string: 0.30.8 /@vue/reactivity@3.2.31: resolution: {integrity: sha512-HVr0l211gbhpEKYr2hYe7hRsV91uIVGFYNHj73njbARVGHQvIojkImKMaZNDdoDZOIkMsBc9a1sMqR+WZwfSCw==} @@ -3186,6 +3189,12 @@ packages: resolution: {integrity: sha512-/7WRafBOshOc6m3F7plwzPeCu/RCVv9uMpOwa/5PiY1Zz+WLVRWiy0MYKwmg19KBdGtFWsmZ4cD+LOdVPcs52A==} dependencies: '@vue/shared': 3.2.37 + dev: true + + /@vue/reactivity@3.3.4: + resolution: {integrity: sha512-kLTDLwd0B1jG08NBF3R5rqULtv/f8x3rOFByTDz4J53ttIQEDmALqKqXY0J+XQeN0aV2FBxY8nJDf88yvOPAqQ==} + dependencies: + '@vue/shared': 3.3.4 /@vue/runtime-core@3.2.31: resolution: {integrity: sha512-Kcog5XmSY7VHFEMuk4+Gap8gUssYMZ2+w+cmGI6OpZWYOEIcbE0TPzzPHi+8XTzAgx1w/ZxDFcXhZeXN5eKWsA==} @@ -3193,11 +3202,11 @@ packages: '@vue/reactivity': 3.2.31 '@vue/shared': 3.2.31 - /@vue/runtime-core@3.2.37: - resolution: {integrity: sha512-JPcd9kFyEdXLl/i0ClS7lwgcs0QpUAWj+SKX2ZC3ANKi1U4DOtiEr6cRqFXsPwY5u1L9fAjkinIdB8Rz3FoYNQ==} + /@vue/runtime-core@3.3.4: + resolution: {integrity: sha512-R+bqxMN6pWO7zGI4OMlmvePOdP2c93GsHFM/siJI7O2nxFRzj55pLwkpCedEY+bTMgp5miZ8CxfIZo3S+gFqvA==} dependencies: - '@vue/reactivity': 3.2.37 - '@vue/shared': 3.2.37 + '@vue/reactivity': 3.3.4 + '@vue/shared': 3.3.4 /@vue/runtime-dom@3.2.31: resolution: {integrity: sha512-N+o0sICVLScUjfLG7u9u5XCjvmsexAiPt17GNnaWHJUfsKed5e85/A3SWgKxzlxx2SW/Hw7RQxzxbXez9PtY3g==} @@ -3206,12 +3215,12 @@ packages: '@vue/shared': 3.2.31 csstype: 2.6.19 - /@vue/runtime-dom@3.2.37: - resolution: {integrity: sha512-HimKdh9BepShW6YozwRKAYjYQWg9mQn63RGEiSswMbW+ssIht1MILYlVGkAGGQbkhSh31PCdoUcfiu4apXJoPw==} + /@vue/runtime-dom@3.3.4: + resolution: {integrity: sha512-Aj5bTJ3u5sFsUckRghsNjVTtxZQ1OyMWCr5dZRAPijF/0Vy4xEoRCwLyHXcj4D0UFbJ4lbx3gPTgg06K/GnPnQ==} dependencies: - '@vue/runtime-core': 3.2.37 - '@vue/shared': 3.2.37 - csstype: 2.6.19 + '@vue/runtime-core': 3.3.4 + '@vue/shared': 3.3.4 + csstype: 3.1.3 /@vue/server-renderer@3.2.31(vue@3.2.31): resolution: {integrity: sha512-8CN3Zj2HyR2LQQBHZ61HexF5NReqngLT3oahyiVRfSSvak+oAvVmu8iNLSu6XR77Ili2AOpnAt1y8ywjjqtmkg==} @@ -3222,14 +3231,24 @@ packages: '@vue/shared': 3.2.31 vue: 3.2.31 - /@vue/server-renderer@3.2.37(vue@3.2.37): + /@vue/server-renderer@3.2.37(vue@3.3.4): resolution: {integrity: sha512-kLITEJvaYgZQ2h47hIzPh2K3jG8c1zCVbp/o/bzQOyvzaKiCquKS7AaioPI28GNxIsE/zSx+EwWYsNxDCX95MA==} peerDependencies: vue: 3.2.37 dependencies: '@vue/compiler-ssr': 3.2.37 '@vue/shared': 3.2.37 - vue: 3.2.37 + vue: 3.3.4 + dev: true + + /@vue/server-renderer@3.3.4(vue@3.3.4): + resolution: {integrity: sha512-Q6jDDzR23ViIb67v+vM1Dqntu+HUexQcsWKhhQa4ARVzxOY2HbC7QRW/ggkDBd5BU+uM1sV6XOAP0b216o34JQ==} + peerDependencies: + vue: 3.3.4 + dependencies: + '@vue/compiler-ssr': 3.3.4 + '@vue/shared': 3.3.4 + vue: 3.3.4 /@vue/shared@3.2.31: resolution: {integrity: sha512-ymN2pj6zEjiKJZbrf98UM2pfDd6F2H7ksKw7NDt/ZZ1fh5Ei39X5tABugtT03ZRlWd9imccoK0hE8hpjpU7irQ==} @@ -3240,13 +3259,17 @@ packages: /@vue/shared@3.2.37: resolution: {integrity: sha512-4rSJemR2NQIo9Klm1vabqWjD8rs/ZaJSzMxkMNeJS6lHiUjjUeYFbooN19NgFjztubEKh3WlZUeOLVdbbUWHsw==} + dev: true - /@vue/test-utils@2.0.0-rc.17(vue@3.2.37): + /@vue/shared@3.3.4: + resolution: {integrity: sha512-7OjdcV8vQ74eiz1TZLzZP4JwqM5fA94K6yntPS5Z25r9HDuGNzaGdgvwKYq6S+MxwF0TFRwe50fIR/MYnakdkQ==} + + /@vue/test-utils@2.0.0-rc.17(vue@3.3.4): resolution: {integrity: sha512-7LHZKsFRV/HqDoMVY+cJamFzgHgsrmQFalROHC5FMWrzPzd+utG5e11krj1tVsnxYufGA2ABShX4nlcHXED+zQ==} peerDependencies: vue: ^3.0.1 dependencies: - vue: 3.2.37 + vue: 3.3.4 dev: true /@vuedx/analyze@0.4.1: @@ -3345,7 +3368,7 @@ packages: - supports-color dev: true - /@vueuse/core@8.9.4(vue@3.2.37): + /@vueuse/core@8.9.4(vue@3.3.4): resolution: {integrity: sha512-B/Mdj9TK1peFyWaPof+Zf/mP9XuGAngaJZBwPaXBvU3aCTZlx3ltlrFFFyMV4iGBwsjSCeUCgZrtkEj9dS2Y3Q==} peerDependencies: '@vue/composition-api': ^1.1.0 @@ -3358,16 +3381,16 @@ packages: dependencies: '@types/web-bluetooth': 0.0.14 '@vueuse/metadata': 8.9.4 - '@vueuse/shared': 8.9.4(vue@3.2.37) - vue: 3.2.37 - vue-demi: 0.12.1(vue@3.2.37) + '@vueuse/shared': 8.9.4(vue@3.3.4) + vue: 3.3.4 + vue-demi: 0.12.1(vue@3.3.4) dev: false /@vueuse/metadata@8.9.4: resolution: {integrity: sha512-IwSfzH80bnJMzqhaapqJl9JRIiyQU0zsRGEgnxN6jhq7992cPUJIRfV+JHRIZXjYqbwt07E1gTEp0R0zPJ1aqw==} dev: false - /@vueuse/shared@8.9.4(vue@3.2.37): + /@vueuse/shared@8.9.4(vue@3.3.4): resolution: {integrity: sha512-wt+T30c4K6dGRMVqPddexEVLa28YwxW5OFIPmzUHICjphfAuBFTTdDoyqREZNDOFJZ44ARH1WWQNCUK8koJ+Ag==} peerDependencies: '@vue/composition-api': ^1.1.0 @@ -3378,8 +3401,8 @@ packages: vue: optional: true dependencies: - vue: 3.2.37 - vue-demi: 0.12.1(vue@3.2.37) + vue: 3.3.4 + vue-demi: 0.12.1(vue@3.3.4) dev: false /JSONStream@1.3.5: @@ -3692,7 +3715,7 @@ packages: engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} dependencies: '@babel/template': 7.16.7 - '@babel/types': 7.17.0 + '@babel/types': 7.22.5 '@types/babel__core': 7.1.18 '@types/babel__traverse': 7.14.2 dev: true @@ -3768,7 +3791,7 @@ packages: resolution: {integrity: sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw==} engines: {node: '>= 10.0.0'} dependencies: - '@babel/types': 7.17.0 + '@babel/types': 7.22.5 dev: true /bail@1.0.5: @@ -4184,7 +4207,7 @@ packages: resolution: {integrity: sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==} dependencies: '@babel/parser': 7.17.3 - '@babel/types': 7.17.0 + '@babel/types': 7.22.5 dev: true /conventional-changelog-angular@5.0.13: @@ -4463,6 +4486,9 @@ packages: /csstype@2.6.19: resolution: {integrity: sha512-ZVxXaNy28/k3kJg0Fou5MiYpp88j7H9hLZp8PDC3jV0WFjfH5E9xHb56L0W59cPbKbcHXeP4qyT8PrHp8t6LcQ==} + /csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + /d3-array@1.2.4: resolution: {integrity: sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==} dev: false @@ -6382,13 +6408,13 @@ packages: /highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} + dev: true /highlight.js@11.6.0: resolution: {integrity: sha512-ig1eqDzJaB0pqEvlPVIpSSyMaO92bH1N2rJpLMN/nX396wTpDA4Eq0uK+7I/2XG17pFaaKE0kjV/XPeGt7Evjw==} engines: {node: '>=12.0.0'} requiresBuild: true dev: false - optional: true /hogan.js@3.0.2: resolution: {integrity: sha512-RqGs4wavGYJWE07t35JQccByczmNUXQT0E12ZYV1VKYu5UiAU9lsos/yBAcf840+zrUQQxgVduCR5/B8nNtibg==} @@ -6588,7 +6614,7 @@ packages: mute-stream: 0.0.8 ora: 5.4.1 run-async: 2.4.1 - rxjs: 7.5.4 + rxjs: 7.8.1 string-width: 4.2.3 strip-ansi: 6.0.1 through: 2.3.8 @@ -7273,7 +7299,7 @@ packages: '@babel/generator': 7.17.3 '@babel/plugin-syntax-typescript': 7.16.7(@babel/core@7.17.5) '@babel/traverse': 7.17.3 - '@babel/types': 7.17.0 + '@babel/types': 7.22.5 '@jest/transform': 27.5.1 '@jest/types': 27.5.1 '@types/babel__traverse': 7.14.2 @@ -7506,6 +7532,13 @@ packages: promise: 7.3.1 dev: true + /katex@0.12.0: + resolution: {integrity: sha512-y+8btoc/CK70XqcHqjxiGWBOeIL8upbS0peTPXTvgrh21n1RiWWcIpSWM+4uXq+IAgNh9YYQWdc7LVDPDAEEAg==} + hasBin: true + dependencies: + commander: 2.20.3 + dev: false + /khroma@2.0.0: resolution: {integrity: sha512-2J8rDNlQWbtiNYThZRvmMv5yt44ZakX+Tz5ZIp/mN1pt4snn+m030Va5Z4v8xA0cQFDXBwO/8i42xL4QPsVk3g==} dev: false @@ -7593,7 +7626,7 @@ packages: log-update: 4.0.0 p-map: 4.0.0 rfdc: 1.3.0 - rxjs: 7.5.4 + rxjs: 7.8.1 through: 2.3.8 wrap-ansi: 7.0.0 dev: true @@ -7700,6 +7733,12 @@ packages: dependencies: sourcemap-codec: 1.4.8 + /magic-string@0.30.8: + resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==} + engines: {node: '>=12'} + dependencies: + '@jridgewell/sourcemap-codec': 1.4.15 + /make-dir@3.1.0: resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} engines: {node: '>=8'} @@ -7739,6 +7778,10 @@ packages: resolution: {integrity: sha512-39j7/9vP/CPCKbEI44oV8yoPJTpvfeReTn/COgRhSpNrjWF3PfP/JUxxB0hxV6ynOY8KH8Y8aX9NMDdo6z+6YQ==} dev: true + /markdown-it-plantuml@1.4.1: + resolution: {integrity: sha512-13KgnZaGYTHBp4iUmGofzZSBz+Zj6cyqfR0SXUIc9wgWTto5Xhn7NjaXYxY0z7uBeTUMlc9LMQq5uP4OM5xCHg==} + dev: false + /markdown-it-table-of-contents@0.5.2: resolution: {integrity: sha512-6o+rxSwzXmXCUn1n8QGTSpgbcnHBG6lUU8x7A5Cssuq5vbfzTfitfGPvQ5PZkp+gP1NGS/DR2rkYqJPn0rbZ1A==} engines: {node: '>6.4.0'} @@ -9030,6 +9073,12 @@ packages: resolution: {integrity: sha512-h5M3Hk78r6wAheJF0a5YahB1yRQKCsZ4MsGdZ5O9ETbVtjPcScGfrMmoOq7EBsCRzd4BDkvDJ7ogP8Sz5tTFiQ==} dependencies: tslib: 2.3.1 + dev: false + + /rxjs@7.8.1: + resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} + dependencies: + tslib: 2.3.1 /safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} @@ -9901,6 +9950,11 @@ packages: hasBin: true dev: true + /uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + hasBin: true + dev: false + /v8-compile-cache@2.3.0: resolution: {integrity: sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==} dev: true @@ -10016,7 +10070,7 @@ packages: '@types/markdown-it': 12.2.3 '@vitejs/plugin-vue': 1.10.2(vite@2.8.4) '@vue/compiler-sfc': 3.2.37 - '@vue/server-renderer': 3.2.37(vue@3.2.37) + '@vue/server-renderer': 3.2.37(vue@3.3.4) chalk: 4.1.2 compression: 1.7.4 debug: 4.3.3(supports-color@8.1.1) @@ -10037,7 +10091,7 @@ packages: prismjs: 1.29.0 sirv: 1.0.19 vite: 2.8.4(sass@1.49.8) - vue: 3.2.37 + vue: 3.3.4 transitivePeerDependencies: - less - react @@ -10057,7 +10111,7 @@ packages: '@vitejs/plugin-vue': 1.10.2(vite@2.8.4) prismjs: 1.29.0 vite: 2.8.4(sass@1.49.8) - vue: 3.2.37 + vue: 3.3.4 transitivePeerDependencies: - less - react @@ -10174,7 +10228,7 @@ packages: '@volar/transforms': 0.29.8 '@volar/vue-code-gen': 0.29.8 '@vscode/emmet-helper': 2.8.4 - '@vue/reactivity': 3.2.31 + '@vue/reactivity': 3.2.37 '@vue/shared': 3.2.37 request-light: 0.5.7 upath: 2.0.1 @@ -10192,7 +10246,7 @@ packages: deprecated: This package has been renamed to @vscode/web-custom-data, please update to the new name dev: true - /vue-demi@0.12.1(vue@3.2.37): + /vue-demi@0.12.1(vue@3.3.4): resolution: {integrity: sha512-QL3ny+wX8c6Xm1/EZylbgzdoDolye+VpCXRhI2hug9dJTP3OUJ3lmiKN3CsVV3mOJKwFi0nsstbgob0vG7aoIw==} engines: {node: '>=12'} hasBin: true @@ -10204,7 +10258,7 @@ packages: '@vue/composition-api': optional: true dependencies: - vue: 3.2.37 + vue: 3.3.4 dev: false /vue-eslint-parser@7.11.0(eslint@7.32.0): @@ -10225,13 +10279,13 @@ packages: - supports-color dev: true - /vue-router@4.0.12(vue@3.2.37): + /vue-router@4.0.12(vue@3.3.4): resolution: {integrity: sha512-CPXvfqe+mZLB1kBWssssTiWg4EQERyqJZes7USiqfW9B5N2x+nHlnsM1D3b5CaJ6qgCvMmYJnz+G0iWjNCvXrg==} peerDependencies: vue: ^3.0.0 dependencies: '@vue/devtools-api': 6.0.12 - vue: 3.2.37 + vue: 3.3.4 dev: false /vue-tsc@0.29.8(typescript@4.5.5): @@ -10264,14 +10318,14 @@ packages: '@vue/server-renderer': 3.2.31(vue@3.2.31) '@vue/shared': 3.2.31 - /vue@3.2.37: - resolution: {integrity: sha512-bOKEZxrm8Eh+fveCqS1/NkG/n6aMidsI6hahas7pa0w/l7jkbssJVsRhVDs07IdDq7h9KHswZOgItnwJAgtVtQ==} + /vue@3.3.4: + resolution: {integrity: sha512-VTyEYn3yvIeY1Py0WaYGZsXnz3y5UnGi62GjVEqvEGPl6nxbOrCXbVOTQWBEJUqAyTUk2uJ5JLVnYJ6ZzGbrSw==} dependencies: - '@vue/compiler-dom': 3.2.37 - '@vue/compiler-sfc': 3.2.37 - '@vue/runtime-dom': 3.2.37 - '@vue/server-renderer': 3.2.37(vue@3.2.37) - '@vue/shared': 3.2.37 + '@vue/compiler-dom': 3.3.4 + '@vue/compiler-sfc': 3.3.4 + '@vue/runtime-dom': 3.3.4 + '@vue/server-renderer': 3.3.4(vue@3.3.4) + '@vue/shared': 3.3.4 /w3c-hr-time@1.0.2: resolution: {integrity: sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==} @@ -10372,7 +10426,7 @@ packages: engines: {node: '>= 10.0.0'} dependencies: '@babel/parser': 7.17.3 - '@babel/types': 7.17.0 + '@babel/types': 7.22.5 assert-never: 1.2.1 babel-walk: 3.0.0-canary-5 dev: true From ec1da27b0ae679bdd25db82ff7711969b15368e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A1=8C=E8=A8=80?= <2311595895@qq.com> Date: Tue, 26 Mar 2024 17:30:56 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20=E7=BB=84=E4=BB=B6=E5=86=85?= =?UTF-8?q?=E7=BD=AE=E5=9B=BE=E6=A0=87=E6=9B=BF=E6=8D=A2=E6=88=90=E7=BB=86?= =?UTF-8?q?=E5=9B=BE=E6=A0=87=20(#1805)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../devui-vue/devui/carousel/src/carousel.tsx | 6 +- .../src/components/carousel-icons.tsx | 26 +++++++++ .../devui/cascader/src/cascader.scss | 28 +++++++++- .../devui-vue/devui/cascader/src/cascader.tsx | 17 ++++-- .../devui/collapse/src/collapse-item.tsx | 4 +- .../devui-vue/devui/icon/src/icon-types.ts | 1 - packages/devui-vue/devui/input/src/input.scss | 27 ++++++++- packages/devui-vue/devui/input/src/input.tsx | 5 +- .../modal/src/components/modal-icons.tsx | 19 +++++++ packages/devui-vue/devui/modal/src/modal.scss | 41 ++++++-------- packages/devui-vue/devui/modal/src/modal.tsx | 3 +- .../pagination/src/components/page-size.tsx | 20 ++++--- .../devui/pagination/src/pagination.scss | 21 ++++++- .../src/components/search-close-icon.tsx | 10 ---- .../search/src/components/search-icon.tsx | 9 --- .../devui-vue/devui/search/src/search.scss | 49 +++++++++++++--- .../devui-vue/devui/search/src/search.tsx | 7 +-- .../src/components/select-arrow-icon.tsx | 13 ----- .../select/src/components/select-content.tsx | 5 +- .../devui-vue/devui/select/src/select.scss | 13 ++++- packages/devui-vue/devui/svg-icons/index.tsx | 56 +++++++++++++++++++ packages/devui-vue/package.json | 2 +- 22 files changed, 277 insertions(+), 105 deletions(-) create mode 100644 packages/devui-vue/devui/carousel/src/components/carousel-icons.tsx create mode 100644 packages/devui-vue/devui/modal/src/components/modal-icons.tsx delete mode 100644 packages/devui-vue/devui/search/src/components/search-close-icon.tsx delete mode 100644 packages/devui-vue/devui/search/src/components/search-icon.tsx delete mode 100644 packages/devui-vue/devui/select/src/components/select-arrow-icon.tsx create mode 100644 packages/devui-vue/devui/svg-icons/index.tsx diff --git a/packages/devui-vue/devui/carousel/src/carousel.tsx b/packages/devui-vue/devui/carousel/src/carousel.tsx index aeaed2d9f6..1f8f201838 100644 --- a/packages/devui-vue/devui/carousel/src/carousel.tsx +++ b/packages/devui-vue/devui/carousel/src/carousel.tsx @@ -1,7 +1,7 @@ import { defineComponent, ref, watch, onMounted, onBeforeUnmount, Fragment, Comment, toRefs } from 'vue'; import type { VNode } from 'vue'; import { carouselProps, DotTrigger, CarouselProps } from './types'; -import { Icon } from '@devui/shared/components/icon'; +import { CarouselArrowLeft, CarouselArrowRight } from './components/carousel-icons'; import { useNamespace } from '@devui/shared/utils'; import './carousel.scss'; @@ -182,10 +182,10 @@ export default defineComponent({ {arrowTrigger.value !== 'never' && showArrow.value ? (
    ) : null} diff --git a/packages/devui-vue/devui/carousel/src/components/carousel-icons.tsx b/packages/devui-vue/devui/carousel/src/components/carousel-icons.tsx new file mode 100644 index 0000000000..0742af52e7 --- /dev/null +++ b/packages/devui-vue/devui/carousel/src/components/carousel-icons.tsx @@ -0,0 +1,26 @@ +export function CarouselArrowLeft(): JSX.Element { + return ( + + + + + + ); +} + +export function CarouselArrowRight(): JSX.Element { + return ( + + + + + + ); +} diff --git a/packages/devui-vue/devui/cascader/src/cascader.scss b/packages/devui-vue/devui/cascader/src/cascader.scss index 3d91d87e7d..7b6f96ae2f 100644 --- a/packages/devui-vue/devui/cascader/src/cascader.scss +++ b/packages/devui-vue/devui/cascader/src/cascader.scss @@ -24,7 +24,28 @@ } &__close { - cursor: pointer; + .close-icon-container { + width: 14px; + height: 14px; + line-height: 14px; + cursor: pointer; + + svg { + width: 14px; + height: 14px; + + path { + fill: $devui-shape-icon-fill; + transition: all $devui-animation-ease-in-out-smooth $devui-animation-duration-slow; + } + + &:hover { + path { + fill: $devui-shape-icon-fill-hover; + } + } + } + } } &__disbaled { @@ -46,6 +67,9 @@ } &--drop-icon-animation { + svg path { + fill: $devui-text; + } transition: transform 0.2s linear; } @@ -83,7 +107,7 @@ } .#{$devui-prefix}-cascader__dropdown--open { - .#{$devui-prefix}-cascader--drop-icon-animation { + .#{$devui-prefix}-cascader__icon { transform: rotate(180deg); } } diff --git a/packages/devui-vue/devui/cascader/src/cascader.tsx b/packages/devui-vue/devui/cascader/src/cascader.tsx index 873d0bd93d..c516e0349d 100644 --- a/packages/devui-vue/devui/cascader/src/cascader.tsx +++ b/packages/devui-vue/devui/cascader/src/cascader.tsx @@ -9,6 +9,7 @@ import { FlexibleOverlay, Placement } from '../../overlay'; import { PopperTrigger } from '../../shared/components/popper-trigger'; import { POPPER_TRIGGER_TOKEN } from '../../shared/components/popper-trigger/src/popper-trigger-types'; import DInput from '../../input/src/input'; +import { SelectArrowIcon, InputClearIcon } from '../../svg-icons'; import './cascader.scss'; export default defineComponent({ @@ -66,12 +67,14 @@ export default defineComponent({ )} {!showClearable.value && (
    - +
    )} {showClearable.value && props.clearable && (
    - +
    + +
    )} @@ -103,15 +106,17 @@ export default defineComponent({ )} {props.filterable && isSearching.value && (
    - {suggestionsList.value.length === 0 - ? - : suggestionsList.value.map((item) => { + {suggestionsList.value.length === 0 ? ( + + ) : ( + suggestionsList.value.map((item) => { return (
    chooseSuggestion(cloneDeep(item))}> {item.labelsString}
    ); - })} + }) + )}
    )} diff --git a/packages/devui-vue/devui/collapse/src/collapse-item.tsx b/packages/devui-vue/devui/collapse/src/collapse-item.tsx index 2237465622..d7a62af9d5 100644 --- a/packages/devui-vue/devui/collapse/src/collapse-item.tsx +++ b/packages/devui-vue/devui/collapse/src/collapse-item.tsx @@ -1,7 +1,7 @@ import { defineComponent, computed, inject, Transition, onMounted, shallowRef } from 'vue'; import { collapseItemProps } from './collapse-types'; import { useNamespace } from '@devui/shared/utils'; -import OpenIcon from './collapse-open-icon'; +import { SelectArrowIcon } from '../../svg-icons'; import { SELECT_TOKEN } from './const'; export default defineComponent({ @@ -65,7 +65,7 @@ export default defineComponent({ onClick={handlerTitleClick}> {ctx.slots.title ? ctx.slots.title() : props.title} - + diff --git a/packages/devui-vue/devui/icon/src/icon-types.ts b/packages/devui-vue/devui/icon/src/icon-types.ts index 60f55f6414..a4494160c8 100644 --- a/packages/devui-vue/devui/icon/src/icon-types.ts +++ b/packages/devui-vue/devui/icon/src/icon-types.ts @@ -5,7 +5,6 @@ export const iconProps = { name: { type: String, default: '', - required: true, }, size: { type: [Number, String], diff --git a/packages/devui-vue/devui/input/src/input.scss b/packages/devui-vue/devui/input/src/input.scss index eb6499e63e..172a02f194 100644 --- a/packages/devui-vue/devui/input/src/input.scss +++ b/packages/devui-vue/devui/input/src/input.scss @@ -49,13 +49,23 @@ } &--sm { - height: $devui-size-sm; + height: 26px; font-size: $devui-font-size-sm; + + .#{$devui-prefix}-input__clear--icon { + width: 14px; + height: 14px; + } } &--lg { - height: $devui-size-lg; + height: 46px; font-size: $devui-font-size-lg; + + .#{$devui-prefix}-input__clear--icon { + width: 18px; + height: 18px; + } } &--feedback { @@ -163,6 +173,19 @@ cursor: pointer; } + &__clear--icon { + path { + fill: $devui-shape-icon-fill; + transition: all $devui-animation-ease-in-out-smooth $devui-animation-duration-slow; + } + + &:hover { + path { + fill: $devui-shape-icon-fill-hover; + } + } + } + &--gray-style:not(.#{$devui-prefix}-input--disabled) { .#{$devui-prefix}-input__wrapper:not(.#{$devui-prefix}-input--error) { background: $devui-gray-5; diff --git a/packages/devui-vue/devui/input/src/input.tsx b/packages/devui-vue/devui/input/src/input.tsx index 8dd904f96b..dca1ad337b 100644 --- a/packages/devui-vue/devui/input/src/input.tsx +++ b/packages/devui-vue/devui/input/src/input.tsx @@ -5,6 +5,7 @@ import { AutoFocus } from '../../auto-focus'; import { inputProps, InputProps } from './input-types'; import { FORM_ITEM_TOKEN, FormItemContext } from '../../form/src/components/form-item/form-item-types'; import { useNamespace } from '@devui/shared/utils'; +import { InputClearIcon } from '../../svg-icons'; import { useInputRender } from './composables/use-input-render'; import { useInputEvent } from './composables/use-input-event'; import { useInputFunction } from './composables/use-input-function'; @@ -101,9 +102,7 @@ export default defineComponent({ onClick={clickPasswordIcon} /> )} - {showClearable.value && ( - - )} + {showClearable.value && } )} diff --git a/packages/devui-vue/devui/modal/src/components/modal-icons.tsx b/packages/devui-vue/devui/modal/src/components/modal-icons.tsx new file mode 100644 index 0000000000..0718310ba2 --- /dev/null +++ b/packages/devui-vue/devui/modal/src/components/modal-icons.tsx @@ -0,0 +1,19 @@ +export function CloseIcon(): JSX.Element { + return ( + + + + + + ); +} diff --git a/packages/devui-vue/devui/modal/src/modal.scss b/packages/devui-vue/devui/modal/src/modal.scss index 50644ac573..b766fa4269 100644 --- a/packages/devui-vue/devui/modal/src/modal.scss +++ b/packages/devui-vue/devui/modal/src/modal.scss @@ -5,7 +5,7 @@ top: 50%; left: 50%; width: 300px; - border-radius: $devui-border-radius; + border-radius: $devui-border-radius-card; border: none; opacity: 1; transform: translate(-50%, -50%); @@ -17,26 +17,14 @@ .btn-close { position: absolute; - right: 16px; - top: 20px; - width: 20px; - height: 20px; - line-height: 20px; - text-align: center; - cursor: pointer; - background: transparent; - border: 0; + right: 20px; + top: 18px; -webkit-appearance: none; + z-index: calc(var(--devui-z-index-modal, 1050) + 1); - &:hover { - color: $devui-icon-fill-active-hover; - background-color: $devui-list-item-hover-bg; - } - - & i { - position: absolute; - right: 0; - top: 0; + .#{$devui-prefix}-icon__container { + display: inline-flex; + align-items: center; } } @@ -70,12 +58,17 @@ .#{$devui-prefix}-modal__header { width: 100%; - height: 56px; + height: 46px; + line-height: 26px; padding: 20px 20px 0; - font-size: $devui-font-size-card-title; + font-size: $devui-font-size-modal-title; font-weight: bold; + letter-spacing: 0; box-sizing: border-box; - border: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + user-select: none; .header-alert-icon { display: inline-block; @@ -100,7 +93,7 @@ padding: 0 32px 24px; box-sizing: border-box; - & > * { + &>* { margin: 0 4px; } } @@ -119,4 +112,4 @@ opacity: 0.2; top: calc(50% - 24px); } -} +} \ No newline at end of file diff --git a/packages/devui-vue/devui/modal/src/modal.tsx b/packages/devui-vue/devui/modal/src/modal.tsx index f389a2f354..43206248a5 100644 --- a/packages/devui-vue/devui/modal/src/modal.tsx +++ b/packages/devui-vue/devui/modal/src/modal.tsx @@ -6,6 +6,7 @@ import { useModal, useModalRender } from './composables/use-modal'; import { useDraggable } from './composables/use-draggable'; import DModalHeader from './components/header'; import DModalBody from './components/body'; +import { CloseIcon } from './components/modal-icons'; import { useNamespace } from '../../shared/hooks/use-namespace'; import './modal.scss'; @@ -107,7 +108,7 @@ export default defineComponent({ style={{ transform: modalPosition.value }}> {showClose.value && (
    - +
    )} {props.type ? ( diff --git a/packages/devui-vue/devui/pagination/src/components/page-size.tsx b/packages/devui-vue/devui/pagination/src/components/page-size.tsx index 09ccf49960..9ae07a3b96 100644 --- a/packages/devui-vue/devui/pagination/src/components/page-size.tsx +++ b/packages/devui-vue/devui/pagination/src/components/page-size.tsx @@ -1,5 +1,5 @@ -import { defineComponent, inject, withModifiers, ref } from 'vue'; -import { Icon } from '@devui/shared/components/icon'; +import { defineComponent, inject, computed, ref } from 'vue'; +import { SelectArrowIcon } from '../../../svg-icons'; import { Dropdown } from '../../../dropdown'; import { useNamespace } from '@devui/shared/utils'; import { paginationInjectionKey, IPagination } from '../pagination-types'; @@ -8,10 +8,15 @@ export default defineComponent({ setup() { const ns = useNamespace('pagination'); const paginationContext = inject(paginationInjectionKey) as IPagination; - const iconRotate = ref(0); + const isOpen = ref(false); const { size, currentPageSize, pageSizeOptions, pageSizeDirection, pageSizeChange, t } = paginationContext; + const pageSizeClasses = computed(() => ({ + [ns.e('size')]: true, + [ns.em('size', size.value)]: Boolean(size.value), + [ns.em('size', 'open')]: isOpen.value, + })); const onDropdownToggle = (e: boolean) => { - iconRotate.value = e ? 180 : 0; + isOpen.value = e; }; return () => ( @@ -19,10 +24,9 @@ export default defineComponent({ {{ default: () => ( -
    - - {{ prefix: () => {currentPageSize.value} }} - +
    + {currentPageSize.value} +
    ), menu: () => ( diff --git a/packages/devui-vue/devui/pagination/src/pagination.scss b/packages/devui-vue/devui/pagination/src/pagination.scss index aead5d84ef..3f57b68172 100644 --- a/packages/devui-vue/devui/pagination/src/pagination.scss +++ b/packages/devui-vue/devui/pagination/src/pagination.scss @@ -26,6 +26,18 @@ box-sizing: border-box; cursor: pointer; + span { + margin-right: 8px; + } + + svg { + transition: transform $devui-animation-ease-in-out-smooth $devui-animation-duration-slow; + + path { + fill: $devui-text; + } + } + &:hover { border-color: $devui-form-control-line-hover; } @@ -42,6 +54,12 @@ height: 46px; } + &.#{$devui-prefix}-pagination__size--open { + svg { + transform: rotate(180deg); + } + } + & > .#{$devui-prefix}-icon__container { display: flex; align-items: center; @@ -389,8 +407,7 @@ text-overflow: ellipsis; white-space: nowrap; cursor: pointer; - transition: - color $devui-animation-duration-fast $devui-animation-ease-in-out-smooth, + transition: color $devui-animation-duration-fast $devui-animation-ease-in-out-smooth, background-color $devui-animation-duration-fast $devui-animation-ease-in-out-smooth; &:hover:not(.active) { diff --git a/packages/devui-vue/devui/search/src/components/search-close-icon.tsx b/packages/devui-vue/devui/search/src/components/search-close-icon.tsx deleted file mode 100644 index deca24bd6e..0000000000 --- a/packages/devui-vue/devui/search/src/components/search-close-icon.tsx +++ /dev/null @@ -1,10 +0,0 @@ -const SearchCloseIcon = (): JSX.Element => ( - - - -); -export default SearchCloseIcon; diff --git a/packages/devui-vue/devui/search/src/components/search-icon.tsx b/packages/devui-vue/devui/search/src/components/search-icon.tsx deleted file mode 100644 index 4d8e5de139..0000000000 --- a/packages/devui-vue/devui/search/src/components/search-icon.tsx +++ /dev/null @@ -1,9 +0,0 @@ -const SearchIcon = (): JSX.Element => ( - - - -); -export default SearchIcon; diff --git a/packages/devui-vue/devui/search/src/search.scss b/packages/devui-vue/devui/search/src/search.scss index ef7a46d40e..7467f35a89 100644 --- a/packages/devui-vue/devui/search/src/search.scss +++ b/packages/devui-vue/devui/search/src/search.scss @@ -51,16 +51,32 @@ & svg { path { - fill: $devui-icon-fill; + fill: $devui-icon-text; } - @include size($devui-font-size-md, $devui-font-size-md); } } &__clear { position: absolute; - right: $devui-size-md; + right: 36px; cursor: pointer; + height: 100%; + font-size: 10px; + @include size(30px, 100%); + @include flex; + + & > svg { + path { + fill: $devui-shape-icon-fill; + transition: all $devui-animation-ease-in-out-smooth $devui-animation-duration-slow; + } + + &:hover { + path { + fill: $devui-shape-icon-fill-hover; + } + } + } &::after { content: ''; @@ -89,15 +105,28 @@ font-size: $devui-font-size-sm; &.#{$devui-prefix}-input--sm { - padding: 0 48px 0 6px; + font-size: $devui-font-size; + padding-right: 60px; } } - .#{$devui-prefix}-search__icon, .#{$devui-prefix}-search__clear { - @include size($devui-size-sm, $devui-size-sm); + .#{$devui-prefix}-search__icon { + @include size(34px, 26px); svg { - @include size($devui-font-size-sm, $devui-font-size-sm); + width: 14px; + height: 14px; + } + } + + .#{$devui-prefix}-search__clear { + @include size(26px, 100%); + + right: 34px; + + & > svg { + width: 14px; + height: 14px; } } @@ -116,11 +145,13 @@ } } - .#{$devui-prefix}-search__icon, .#{$devui-prefix}-search__clear { + .#{$devui-prefix}-search__icon, + .#{$devui-prefix}-search__clear { @include size($devui-size-lg, $devui-size-lg); svg { - @include size($devui-font-size-lg, $devui-font-size-lg); + width: 18px; + height: 18px; } } diff --git a/packages/devui-vue/devui/search/src/search.tsx b/packages/devui-vue/devui/search/src/search.tsx index af73ad9f4f..3b6570ae86 100644 --- a/packages/devui-vue/devui/search/src/search.tsx +++ b/packages/devui-vue/devui/search/src/search.tsx @@ -7,8 +7,7 @@ import DInput from '../../input/src/input'; import { useNamespace } from '../../shared/hooks/use-namespace'; import './search.scss'; import { createI18nTranslate } from '../../locale/create'; -import SearchCloseIcon from './components/search-close-icon'; -import SearchIcon from './components/search-icon'; +import { SearchIcon, InputClearIcon } from '../../svg-icons'; export default defineComponent({ name: 'DSearch', @@ -20,7 +19,7 @@ export default defineComponent({ const ns = useNamespace('search'); const isFocus = ref(false); - const {rootClass, searchSize} = useSearchClass(props, isFocus); + const { rootClass, searchSize } = useSearchClass(props, isFocus); const { keywords, clearIconShow, onClearHandle } = keywordsHandles(ctx, props); const { onInputKeydown, onClickHandle, useEmitKeyword } = keydownHandles(ctx, keywords, props); @@ -63,7 +62,7 @@ export default defineComponent({ {clearIconShow.value && (
    - +
    )} {props.iconPosition === 'right' && ( diff --git a/packages/devui-vue/devui/select/src/components/select-arrow-icon.tsx b/packages/devui-vue/devui/select/src/components/select-arrow-icon.tsx deleted file mode 100644 index 280be2bd1e..0000000000 --- a/packages/devui-vue/devui/select/src/components/select-arrow-icon.tsx +++ /dev/null @@ -1,13 +0,0 @@ -const SelectArrowIcon = (): JSX.Element => ( - - - - - -); -export default SelectArrowIcon; diff --git a/packages/devui-vue/devui/select/src/components/select-content.tsx b/packages/devui-vue/devui/select/src/components/select-content.tsx index d3c90b5b40..2e05aab24e 100644 --- a/packages/devui-vue/devui/select/src/components/select-content.tsx +++ b/packages/devui-vue/devui/select/src/components/select-content.tsx @@ -1,6 +1,5 @@ import { defineComponent, inject, computed, withModifiers } from 'vue'; -import AlertCloseIcon from '../../../alert/src/components/alert-close-icon'; -import SelectArrowIcon from './select-arrow-icon'; +import { SelectArrowIcon, InputClearIcon } from '../../../svg-icons'; import { Tag, SizeType } from '../../../tag'; import { Popover } from '../../../popover'; import { useNamespace } from '../../../shared/hooks/use-namespace'; @@ -121,7 +120,7 @@ export default defineComponent({ /> )} - + diff --git a/packages/devui-vue/devui/select/src/select.scss b/packages/devui-vue/devui/select/src/select.scss index 72393681e1..cc9ddb81c5 100644 --- a/packages/devui-vue/devui/select/src/select.scss +++ b/packages/devui-vue/devui/select/src/select.scss @@ -227,8 +227,17 @@ $select-item-min-height: 36px; .#{$devui-prefix}-select__clear { display: none; - svg path { - fill: $devui-text-weak; + svg { + path { + fill: $devui-shape-icon-fill; + transition: all $devui-animation-ease-in-out-smooth $devui-animation-duration-slow; + } + + &:hover { + path { + fill: $devui-shape-icon-fill-hover; + } + } } } diff --git a/packages/devui-vue/devui/svg-icons/index.tsx b/packages/devui-vue/devui/svg-icons/index.tsx new file mode 100644 index 0000000000..33657bdf88 --- /dev/null +++ b/packages/devui-vue/devui/svg-icons/index.tsx @@ -0,0 +1,56 @@ +export function SelectArrowIcon(): JSX.Element { + return ( + + + + + + ); +} + +export function SearchIcon(): JSX.Element { + return ( + + + + + + ); +} + +export function InputClearIcon(): JSX.Element { + return ( + + + + + + ); +} diff --git a/packages/devui-vue/package.json b/packages/devui-vue/package.json index 582e89fd70..c2aa3ab74c 100644 --- a/packages/devui-vue/package.json +++ b/packages/devui-vue/package.json @@ -1,6 +1,6 @@ { "name": "vue-devui", - "version": "1.6.4", + "version": "1.6.4-alpha.0", "license": "MIT", "description": "DevUI components based on Vite and Vue3", "keywords": [