Giter Club home page Giter Club logo

vue3-virtual-scroller's Introduction

vue3-virtual-scroller

npm npm vue3

⚠️this is fork project. Originally intended for emergency use. Please use the supported Vue3 version vue-virtual-scroller@next.

Blazing fast scrolling of any amount of data | Live demo | Video demo

Become a Patreon

Sponsors

sponsors logos

Table of contents

Installation

npm install --save vue3-virtual-scroller

⚠️ vue3-virtual-scroller now uses vue-observe-visibility to automatically refresh itself when shown to prevent display glitches. This means you need to include the Intersection Observer polyfill needed by vue-observe-visibility for this to work in old browsers (like Internet Explorer).

Default import

Install all the components:

import VueVirtualScroller from 'vue3-virtual-scroller'

app.use(VueVirtualScroller)

Use specific components:

import { RecycleScroller } from 'vue3-virtual-scroller'

app.component('RecycleScroller', RecycleScroller)

⚠️ The line below should be included when importing the package:

import 'vue3-virtual-scroller/dist/vue3-virtual-scroller.css'

Browser

<link rel="stylesheet" href="vue3-virtual-scroller/dist/vue3-virtual-scroller.css"/>

<script src="vue.js"></script>
<script src="vue3-virtual-scroller/dist/vue3-virtual-scroller.min.js"></script>

If Vue is detected, the plugin will be installed automatically. If not, install the component:

app.use(VueVirtualScroller)

Or register it with a custom name:

app.component('RecycleScroller', VueVirtualScroller.RecycleScroller)

Usage

There are several components provided by vue-virtual-scroller:

RecycleScroller is a component that only renders the visible items in your list. It also re-uses components and dom elements to be as efficient and performant as possible.

DynamicScroller is a component that wraps the RecycleScroller component and extends its features to include dynamic size management. The main use case for this is when you do not know the size of the items in advance. The Dynamic Scroller automatically "discovers" item dimensions as it renders new items during scrolling.

DynamicScrollerItem must wrap each item in a DynamicScroller to handle size computations.

IdState is a mixin that ease the local state management in reused components inside a RecycleScroller.

RecycleScroller

RecycleScroller is a virtual scroller that only renders the visible items. As the user scrolls, RecycleScroller reuses all components and DOM nodes to maintain optimal performance.

Basic usage

Use the scoped slot to render each item in the list:

<template>
  <RecycleScroller
    class="scroller"
    :items="list"
    :item-size="32"
    key-field="id"
    v-slot="{ item }"
  >
    <div class="user">
      {{ item.name }}
    </div>
  </RecycleScroller>
</template>

<script>
import { defineComponent } from 'vue'

export default defineComponent({
  props: {
    list: Array,
  },
})
</script>

<style scoped>
.scroller {
  height: 100%;
}

.user {
  height: 32%;
  padding: 0 12px;
  display: flex;
  align-items: center;
}
</style>

Important notes

  • ⚠️ You need to set the size of the virtual-scroller element and the items elements (for example, with CSS). Unless you are using variable size mode, all items should have the same height (or width in horizontal mode) to prevent display glitches.
  • ⚠️ If the items are objects, the scroller needs to be able to identify them. By default it will look for an id field on the items. This can be configured with the keyField prop if you are using another field name.
  • It is not recommended to use functional components inside RecycleScroller since the components are reused (so it will actually be slower).
  • The list item components must be reactive to the item prop being updated without being re-created (use computed props or watchers to properly react to props changes!).
  • You don't need to set key on list content (but you should on all nested <img> elements to prevent load glitches).
  • The browsers have a size limitation on DOM elements, it means that currently the virtual scroller can't display more than ~500k items depending on the browser.
  • Since DOM elements are reused for items, it's recommended to define hover styles using the provided hover class instead of the :hover state selector (e.g. .vue-recycle-scroller__item-view.hover or .hover .some-element-inside-the-item-view).

How does it work?

  • The RecycleScroller creates pools of views to render visible items to the user.
  • A view holds a rendered item, and is reused inside its pool.
  • For each type of item, a new pool is created so that the same components (and DOM trees) are reused for the same type.
  • Views can be deactivated if they go off-screen, and can be reused anytime for a newly visible item.

Here is what the internals of RecycleScroller look like in vertical mode:

<RecycleScroller>
  <!-- Wrapper element with a pre-calculated total height -->
  <wrapper
    :style="{ height: computedTotalHeight + 'px' }"
  >
    <!-- Each view is translated to the computed position -->
    <view
      v-for="view of pool"
      :style="{ transform: 'translateY(' + view.computedTop + 'px)' }"
    >
      <!-- Your elements will be rendered here -->
      <slot
        :item="view.item"
        :index="view.nr.index"
        :active="view.nr.used"
      />
    </view>
  </wrapper>
</RecycleScroller>

When the user scrolls inside RecycleScroller, the views are mostly just moved around to fill the new visible space, and the default slot properties updated. That way we get the minimum amount of components/elements creation and destruction and we use the full power of Vue virtual-dom diff algorithm to optimize DOM operations!

Props

  • items: list of items you want to display in the scroller.
  • direction (default: 'vertical'): scrolling direction, either 'vertical' or 'horizontal'.
  • itemSize (default: null): display height (or width in horizontal mode) of the items in pixels used to calculate the scroll size and position. If it is set to null (the default value), it will use variable size mode.
  • minItemSize: minimum size used if the height (or width in horizontal mode) of a item is unknown.
  • sizeField (default: 'size'): field used to get the item's size in variable size mode.
  • typeField (default: 'type'): field used to differentiate different kinds of components in the list. For each distinct type, a pool of recycled items will be created.
  • keyField (default: 'id'): field used to identify items and optimize managing rendered views.
  • pageMode (default: false): enable Page mode.
  • prerender (default: 0): render a fixed number of items for Server-Side Rendering (SSR).
  • buffer (default: 200): amount of pixel to add to edges of the scrolling visible area to start rendering items further away.
  • emitUpdate (default: false): emit a 'update' event each time the virtual scroller content is updated (can impact performance).

Events

  • resize: emitted when the size of the scroller changes.
  • visible: emitted when the scroller considers itself to be visible in the page.
  • hidden: emitted when the scroller is hidden in the page.
  • update (startIndex, endIndex): emitted each time the views are updated, only if emitUpdate prop is true

Default scoped slot props

  • item: item being rendered in a view.
  • index: reflects each item's position in the items array
  • active: whether or not the view is active. An active view is considered visible and being positioned by RecycleScroller. An inactive view is not considered visible and is hidden from the user. Any rendering-related computations should be skipped if the view is inactive.

Other Slots

<main>
  <slot name="before"></slot>
  <wrapper>
    <!-- Reused view pools here -->
  </wrapper>
  <slot name="after"></slot>
</main>

Example:

<RecycleScroller
  class="scroller"
  :items="list"
  :item-size="32"
>
  <template #before>
    Hey! I'm a message displayed before the items!
  </template>

  <template v-slot="{ item }">
    <div class="user">
      {{ item.name }}
    </div>
  </template>
</RecycleScroller>

Page mode

The page mode expands the virtual-scroller and uses the page viewport to compute which items are visible. That way, you can use it in a big page with HTML elements before or after (like a header and a footer). Set the page-mode prop to true:

<header>
  <menu></menu>
</header>

<RecycleScroller page-mode>
  <!-- ... -->
</RecycleScroller>

<footer>
  Copyright 2017 - Cat
</footer>

Variable size mode

⚠️ This mode can be performance heavy with a lot of items. Use with caution.

If the itemSize prop is not set or is set to null, the virtual scroller will switch to variable size mode. You then need to expose a number field on the item objects with the size of the item element.

⚠️ You still need to set the size of the items with CSS correctly (with classes for example).

Use the sizeField prop (default is 'size') to set the field used by the scroller to get the size for each item.

Example:

const items = [
  {
    id: 1,
    label: 'Title',
    size: 64,
  },
  {
    id: 2,
    label: 'Foo',
    size: 32,
  },
  {
    id: 3,
    label: 'Bar',
    size: 32,
  },
]

Buffer

You can set the buffer prop (in pixels) on the virtual-scroller to extend the viewport considered when determining the visible items. For example, if you set a buffer of 1000 pixels, the virtual-scroller will start rendering items that are 1000 pixels below the bottom of the scroller visible area, and will keep the items that are 1000 pixels above the top of the visible area.

The default value is 200.

<RecycleScroller :buffer="200" />

Server-Side Rendering

The prerender props can be set as the number of items to render on the server inside the virtual scroller:

<RecycleScroller
  :items="items"
  :item-size="42"
  :prerender="10"
>

DynamicScroller

This works just like the RecycleScroller, but it can render items with unknown sizes!

Basic usage

<template>
  <DynamicScroller
    :items="items"
    :min-item-size="54"
    class="scroller"
  >
    <template v-slot="{ item, index, active }">
      <DynamicScrollerItem
        :item="item"
        :active="active"
        :size-dependencies="[
          item.message,
        ]"
        :data-index="index"
      >
        <div class="avatar">
          <img
            :src="item.avatar"
            :key="item.avatar"
            alt="avatar"
            class="image"
          >
        </div>
        <div class="text">{{ item.message }}</div>
      </DynamicScrollerItem>
    </template>
  </DynamicScroller>
</template>

<script>
import { defineComponent } from 'vue'

export default defineComponent({
  props: {
    items: Array,
  },
})
</script>

<style scoped>
.scroller {
  height: 100%;
}
</style>

Important notes

  • minItemSize is required for the initial render of items.
  • DynamicScroller won't detect size changes on its own, but you can put values that can affect the item size with size-dependencies on DynamicScrollerItem.
  • You don't need to have a size field on the items.

Props

Extends all the RecycleScroller props.

  • It's not recommended to change sizeField prop since all the size management is done internally.

Events

Extends all the RecycleScroller events.

Default scoped slot props

Extends all the RecycleScroller scoped slot props.

Other slots

Extends all the RecycleScroller other slots.

DynamicScrollerItem

The component that should wrap all the items in a DynamicScroller.

Props

  • item (required): the item rendered in the scroller.
  • active (required): is the holding view active in RecycleScroller. Will prevent unnecessary size recomputation.
  • sizeDependencies: values that can affect the size of the item. This prop will be watched and if one value changes, the size will be recomputed. Recommended instead of watchData.
  • watchData (default: false): deeply watch item for changes to re-calculate the size (not recommended, can impact performance).
  • tag (default: 'div'): element used to render the component.
  • emitResize (default: false): emit the resize event each time the size is recomputed (can impact performance).

Events

  • resize: emitted each time the size is recomputed, only if emitResize prop is true.

IdState

This is convenience mixin that can replace data in components being rendered in a RecycleScroller.

Why is this useful?

Since the components in RecycleScroller are reused, you can't directly use the Vue standard data properties: otherwise they will be shared with different items in the list!

IdState will instead provide an idState object which is equivalent to $data, but it's linked to a single item with its identifier (you can change which field with idProp param).

Example

In this example, we use the id of the item to have a "scoped" state to the item:

<template>
  <div class="question">
    <p>{{ item.question }}</p>
    <button @click="idState.replyOpen = !idState.replyOpen">Reply</button>
    <textarea
      v-if="idState.replyOpen"
      v-model="idState.replyText"
      placeholder="Type your reply"
    />
  </div>
</template>

<script>
import { IdState } from 'vue3-virtual-scroller'
import { defineComponent } from 'vue'

export default defineComponent({
  mixins: [
    IdState({
      // You can customize this
      idProp: vm => vm.item.id,
    }),
  ],

  props: {
    // Item in the list
    item: Object,
  },

  // This replaces data () { ... }
  idState () {
    return {
      replyOpen: false,
      replyText: '',
    }
  },
}
</script>

Parameters

  • idProp (default: vm => vm.item.id): field name on the component (for example: 'id') or function returning the id.

License

MIT

vue3-virtual-scroller's People

Contributors

akryum avatar yoonasy avatar dam1r89 avatar alexsasharegan avatar tibineagu avatar vademenko avatar maggiehe avatar steambap avatar buhrmi avatar herteby avatar whizkydee avatar ktsn avatar jrast avatar akernet avatar curtdp avatar drewjbartlett avatar simplesmiler avatar shishiv30 avatar caugner avatar chrisvfritz avatar iprit avatar

Stargazers

heiyehk avatar jiaobingqian avatar  avatar fzchen avatar

Watchers

James Cloos avatar  avatar

vue3-virtual-scroller's Issues

About component usage

hi~
I used the vue3-virtual-scroller component in the project
code show as below

<template>
  <div class="home-index-full home-index-head">
    <DynamicScroller
      class="scroller"
      :items="resetMenuList"
      key-field="name"
      :min-item-size="80"
    >
      <template #default="{ item, index, active }">
        <DynamicScrollerItem
          :item="item"
          :active="active"
          :size-dependencies="[
            item.message,
          ]"
          :data-index="index"
        >
          <menu-warp :base-data="item" />
        </DynamicScrollerItem>
      </template>
    </DynamicScroller>
  </div>
</template>

You can see the elements normally,However, no virtual scrolling effect is seen!

Dragging the scrollbar quickly results in empty content sometimes

Hello, first of all, thank you very much for the work you have done to support vue3 with vue-virtual-scroller. It is great, but I encountered a small problem. The problem is: when I drag the scroll bar quickly (of course I release the mouse after the drag is completed), I sometimes encounter the problem that the content cannot be loaded and no error is reported. However, after this problem occurs, when I scroll the wheel, the content will appear normally. I'm very confused. I hope you can help me. Thank you very much.

// table_utils.js
const Events = {
  Change: "change",
  CellChange: "cell-change",
  Error: "error",
  Delete: "delete",
  Operate: "Operate",
  Blur: "blur",
  Focus: "focus",
  UpdateModelValue: "update:modelValue",
  Expand: "expand",
};
const { h, defineComponent, ref } = Vue;
const { ElInput, ElButton, ElIcon, ElTooltip } = ElementPlus;
const { Close } = ElementPlusIconsVue;
const { cloneDeep, isNumber, isUndefined } = _;

const CustomInput = {
  name: "custom-input",
  emits: [
    Events.Change,
    Events.Error,
    Events.Focus,
    Events.Blur,
    Events.UpdateModelValue,
  ],
  components: { ElInput },
  props: {
    modelValue: {
      required: true,
      type: [String, Number, undefined],
    },
    config: {
      required: true,
      type: Object,
    },
  },
  template: /*html*/ `
    <el-input
      :class="getInputCss"
      ref="inputRef"
      v-model="value"
      @input="onInput"
      @change="onChange"
      @blur="onBlur"
      @focus="onFocus"
      @mouseover="onMouseOver"
      @mouseleave="onMouseLeave"
      size="small"
    >
    </el-input>
  `,
  data() {
    return {
      value: cloneDeep(this.modelValue),
      error: "",
    };
  },
  computed: {
    getInputCss() {
      return {
        "is-error": Boolean(this.error),
      };
    },
  },
  watch: {
    modelValue: {
      handler(new_value) {
        this.value = cloneDeep(new_value);
      },
    },
  },
  methods: {
    focus() {
      this.$refs.inputRef.focus();
    },
    onMouseOver() {
      console.log("custom input onMouseOver");
      if (this.error) this.$emit(Events.Error, this.error, this.$refs.inputRef);
    },
    onMouseLeave() {
      if (this.error) this.$emit(Events.Error); // 取消错误框的显示
    },
    onBlur() {
      this.validate();
      if (this.error) return;
      this.$emit(Events.Blur);
    },
    onFocus() {
      this.$emit(Events.Focus);
    },
    onInput() {
      this.validate();
    },
    onChange() {
      this.validate();
      if (this.error) return;
      const value = outFormatter(this.config.outType, this.value);
      this.value = value;
      this.$emit(Events.Change, value);
      this.$emit(Events.UpdateModelValue, value);
    },
    validate() {
      if (this.config.required) {
        this.error = requiredValidate(this.value);
        if (this.error) {
          this.$emit(Events.Error, this.error, this.$refs.inputRef);
          return;
        }
      }
      if (!this.config.validate) return;
      else {
        this.error = this.config.validate(this.value);
        this.$emit(Events.Error, this.error, this.$refs.inputRef);
        return;
      }
    },
  },
};

const EditableText = {
  name: "editable-text",
  emits: [Events.Change, Events.Error],
  components: { CustomInput },
  props: {
    rowData: {
      required: true,
      type: Object,
    },
    column: {
      required: true,
      type: Object,
    },
  },
  template: /*html*/ `
    <div class="editable-text">
      <custom-input
        v-if="isEdit"
        ref="inputRef"
        v-model="value"
        :config="column"
        @change="onChange"
        @blur="onExitEditMode"
        @focus="onEnterEditMode"
        @error="onError"
        @mouseover="onMouseOver"
        @mouseleave="onMouseLeave"
      >
      </custom-input>
      <div v-else @dblclick="onEnterEditMode" :title="value">
        {{formattedText}}
      </div>
    </div>
  `,
  data() {
    return {
      editing: false,
      value: cloneDeep(this.rowData[this.column.dataKey]),
      error: "",
    };
  },
  computed: {
    isEdit() {
      return this.rowData._creating || this.editing;
    },
    formattedText() {
      if (this.column.textFormatter)
        return this.column.textFormatter(this.value);
      else return this.value;
    },
  },
  watch: {
    rowData: {
      handler(new_value) {
        this.value = new_value[this.column.dataKey];
      },
    },
  },
  methods: {
    onMouseOver() {
      if (this.error) this.$emit(Events.Error, this.error, this.$refs.inputRef);
    },
    onMouseLeave() {
      if (this.error) this.$emit(Events.Error); // 取消错误框的显示
    },
    onExitEditMode() {
      this.editing = false;
    },
    onEnterEditMode() {
      if (!this.rowData._editable) return;
      this.editing = true;
      this.$nextTick(() => {
        this.$refs.inputRef.focus();
      });
    },
    onChange(value) {
      this.rowData[this.column.dataKey] = this.value;
      this.editing = false;
      this.$emit(Events.Change, value);
    },
    onError(...args) {
      this.$emit(Events.Error, ...args);
    },
  },
};

const DeleteIcon = {
  name: "delete-icon",
  emits: [Events.Delete],
  components: { Close, ElButton, ElIcon },
  template: /*html*/ `
    <el-button
      circle
      type="danger"
      size="small"
      class="delete_button"
      @click="$emit('${Events.Delete}')"
    >
      <el-icon>
        <Close />
      </el-icon>
    </el-button>
  `,
};

const createTextFormatter = (precision = 4) => {
  return (value) => {
    if (value === null) return "-";
    else {
      const num = Number(value);
      return Number.isNaN(num) ? value : num.toFixed(precision);
    }
  };
};

const outFormatter = (type, value) => {
  let _value;
  switch (type) {
    case "number":
      _value = Number(value);
      break;
    default:
      _value = value;
      break;
  }
  return _value;
};

const numberValidate = (value) => {
  const num = Number(value);
  if (Number.isNaN(num) || !isNumber(num)) return "please input number";
  else return "";
};

const requiredValidate = (value) => {
  if ([undefined, ""].includes(value)) {
    return "field required";
  } else return "";
};

const ExpandText = {
  name: "expand-text",
  emits: [Events.UpdateModelValue],
  props: {
    modelValue: {
      type: Boolean,
      required: true,
    },
  },
  template: /*html*/ `
        <div class="expand-icon" @click="onClick">
            <div :class="getEditableTextClass"></div>
            <div class="expand-icon-label">
                <slot></slot>
            </div>
        </div>
    `,
  data() {
    return {};
  },
  computed: {
    getEditableTextClass() {
      return {
        "expand-icon-box": true,
        expand: this.modelValue,
      };
    },
  },
  methods: {
    onClick() {
      this.$emit(Events.UpdateModelValue, !this.modelValue);
    },
  },
};

const TableCellComponent = {
  ExpandText: "expand-text",
  EditableText: "editable-text",
  DeleteIcon: "delete-icon",
  Text: "text",
  Input: "input",
};

const TableCell = defineComponent({
  name: "table-cell",
  emits: [Events.Change, Events.Expand, Events.Error],
  components: {
    ExpandText,
    EditableText,
    DeleteIcon,
    CustomInput,
  },
  props: {
    rowData: {
      type: Object,
      required: true,
    },
    column: {
      type: Object,
      required: true,
    },
  },
  template: /*html*/ `
    <div>
      <div v-if="getComponent === 'editable-text'">
        <editable-text
          :rowData="rowData"
          :column="column"
          @change="handleChange"
          @error="onError"
        >
      </editable-text>
      </div>
      <div v-else-if="getComponent === 'delete'">
        <delete-icon @delete="handleOperate('delete', rowData, column)"></delete-icon>
      </div>
      <div v-else-if="getComponent === 'expand-text'">
        <expand-text
          v-model="rowData.is_expand"
          @update:modelValue="handleExpand"
        >
          {{ rowData[column.dataKey] }}
        </expand-text>
      </div>
      <div v-else-if="getComponent === 'text'">
        {{ rowData[column.dataKey] }}
      </div>
      <div v-else-if="getComponent === 'input'">
        <custom-input v-model="rowData[column.dataKey]" :config="column" change="handleChange" @error="onError"></custom-input>
      </div>
    </div>
  `,
  computed: {
    getComponent() {
      if (this.column.component) return this.column.component;
      const component_index = isUndefined(this.rowData.children) ? 1 : 0;
      return this.column.components[component_index];
    },
  },
  methods: {
    handleChange(value) {
      this.$emit(Events.Change, value, this.rowData, this.column);
    },
    onError(msg, errorRef) {
      return this.$emit(Events.Error, msg, errorRef);
    },
    handleOperate(operation, rowData, column) {
      if (operation === "delete") {
        this.$parent.$parent.handleOperate(operation, rowData, column);
      }
    },
    handleExpand(value) {
      this.rowData.is_expand = value;
      this.$emit(Events.Expand, value);
    },
    isUndefined(value) {
      return typeof value === "undefined";
    },
  },
});

const popperOptions = {
  modifiers: [
    {
      name: "computeStyles",
      options: {
        adaptive: false,
        enabled: false,
      },
    },
  ],
};

const VirtualTable = defineComponent({
  name: "virtual-table",
  props: {
    tableData: {
      type: Array,
      required: true,
    },
    maxTableHeight: {
      type: Number,
      default: 500,
    },
    columns: {
      type: Array,
      required: true,
    },
    addition: {
      type: Boolean,
      default: false,
    },
  },
  components: {
    RecycleScroller: Vue3VirtualScroller.RecycleScroller,
    TableCell,
    ElTooltip,
    ElButton,
  },
  template: /*html*/ `
    <div class="virtual-table">
      <table class="fixed-header">
        <thead>
          <tr>
            <th v-for="column in getColumns" :style="{width: column.width}">{{column.title}}</th>
          </tr>
        </thead>
      </table>
      <div class="scroller-container">
        <recycle-scroller
          ref="scroller"
          class="scroller"
          :items="rows"
          :item-size="itemSize"
          key-field="id"
          :buffer="2000"
          :style="{height: getTableHeight}"
          v-slot="{ item, index }"
        >
          <tr >
            <td v-for="column in getColumns">
              <table-cell
                :row-data="item"
                :column="column"
                :style="{width: column.width}"
                @change="handleCellChange"
                @expand="handleExpandElement(item, index)"
                @error="handleInputError"
              >
              </table-cell>
            </td>
          </tr>
        </recycle-scroller>
      </div>
      <table class="addtion-table">
        <tr v-for="(item, index) in getAdditonData">
          <td v-for="column in getAdditonColumns">
            <table-cell
              :row-data="item"
              :column="column"
              :style="{width: column.width}"
              @change="handleCellChange"
              @error="handleInputError"
            >
            </table-cell>
          </td>
        </tr>
      </table>
      <div v-if="addition" class="addition-button">
          <el-button type="primary" @click="handlerAddRowClick" round> + </el-button>
          <el-button
              v-if="showSubmitButton"
              type="primary"
              @click="handlerSubmitRowClick"
              round
          >

          </el-button>
        </div>
        <el-tooltip
          v-if="error"
          popper-class="property-error-popper"
          effect="light"
          :content="error"
          :visible="Boolean(error)"
          :virtual-ref="errorRef"
          virtual-triggering
          :popper-options="popperOptions"
          trigger="hover"
        ></el-tooltip>
    </div>
  `,
  data() {
    return {
      rows: this.initRows(),
      error: "",
      itemSize: 23,
      errorRef: ref(),
      temp_rows: {},
      popperOptions,
    };
  },
  computed: {
    getTableHeight() {
      const height = this.rows.length * this.itemSize;
      return `${Math.min(this.maxTableHeight, height)}px`;
    },
    element_map() {
      const element_map = {};
      this.tableData.forEach((element_row) => {
        element_map[element_row.name] = element_row.children;
      });
      return element_map;
    },
    getAdditonData() {
      return Object.values(this.temp_rows);
    },
    getAdditonColumns() {
      return this.getColumns.map((item) => {
        return {
          ...item,
          component: item.component ?? item.components[2],
        };
      });
    },
    getColumns() {
      return this.columns.map((item) => {
        if (item.textFormatter) {
          const _textFormatter = item.textFormatter;
          item.textFormatter = (...args) => _textFormatter(...args, this);
        }
        if (item.cellRenderer) {
          const _cellRenderer = item.cellRenderer;
          item.cellRenderer = (...args) => _cellRenderer(...args, this);
        }
        if (item.validate) {
          const _validate = item.validate;
          item.validate = (...args) => _validate(...args, this);
        }
        return item;
      });
    },
    getColumnsMap() {
      const maps = {};
      const columns = this.getColumns;
      Object.keys(columns).forEach((index) => {
        const value = columns[index];
        maps[value.dataKey] = columns[index];
      });
      return maps;
    },
    showSubmitButton() {
      return Boolean(Object.keys(this.temp_rows).length);
    },
  },
  mounted() {
    this.initRows();
    this.fixScrollEmptyBug();
  },
  methods: {
    initRows() {
      const rows = new Array(this.tableData.length);
      for (let i = 0; i < this.tableData.length; i += 1) {
        rows[i] = this.tableData[i];
      }
      return rows;
    },
    handleExpandElement(item, index) {
      if (item.is_expand) {
        this.rows[index].is_expand = true;

        // 计算新数组的大小
        let newSize = this.rows.length + this.element_map[item.name].length;

        // 创建一个新的具有正确大小的数组
        let newArray = new Array(newSize);

        // 复制插入点之前的数据
        for (let i = 0; i <= index; i++) {
          newArray[i] = this.rows[i];
        }

        // 插入新数据
        for (let i = 0; i < this.element_map[item.name].length; i++) {
          newArray[index + 1 + i] = this.element_map[item.name][i];
        }

        // 复制插入点之后的数据
        for (let i = index + 1; i < this.rows.length; i++) {
          newArray[this.element_map[item.name].length + i] = this.rows[i];
        }

        // 使用新数组替换旧数组
        this.rows = newArray;
      } else {
        this.rows[index].is_expand = false;

        // 计算新数组的大小
        let newSize = this.rows.length - this.element_map[item.name].length;

        // 创建一个新的具有正确大小的数组
        let newArray = new Array(newSize);

        // 复制删除点之前的数据
        for (let i = 0; i < index + 1; i++) {
          newArray[i] = this.rows[i];
        }

        // 复制删除点之后的数据,跳过要删除的元素
        for (
          let i = index + 1 + this.element_map[item.name].length;
          i < this.rows.length;
          i++
        ) {
          newArray[i - this.element_map[item.name].length] = this.rows[i];
        }

        // 使用新数组替换旧数组
        this.rows = newArray;
      }
    },
    handleInputError(msg, inputRef) {
      if (isUndefined(msg)) {
        // 取消错误提示框
        this.error = "";
        return;
      }
      this.error = msg;
      this.errorRef = inputRef;
    },
    handleCellChange(value, rowData, column) {
      if (rowData._creating) return;
      this.$emit(Events.CellChange, value, rowData, column);
    },
    handleOperate(op_type, rowData, column) {
      if (op_type === "delete" && rowData._creating) {
        this.clearSubmitItem(rowData.id);
        return;
      }
      this.$emit(Events.Operate, op_type, rowData, column);
    },
    clearSubmitItem(id) {
      delete this.temp_rows[id];
    },
    /**
     * @description 提交成功后的清理工作
     */
    clearSubmit(clear_all = false) {
      if (clear_all) {
        const temp_rows_keys = Object.keys(this.temp_rows);
        if (temp_rows_keys.length) {
          temp_rows_keys.forEach((key) => {
            this.clearSubmitItem(key);
          });
        }
      }
    },
    handlerAddRowClick() {
      const uuid = String(new Date().getTime());
      const new_row = {
        id: uuid,
        children: [],
        _creating: true,
      };
      this.temp_rows[new_row.id] = new_row;
      this.getColumns.forEach((column) => {
        if (column.dataKey) new_row[column.dataKey] = "";
      });
    },
    handlerSubmitRowClick() {
      let temp_rows = Object.values(cloneDeep(this.temp_rows));
      temp_rows = temp_rows.filter((row) => {
        const columnsMap = this.getColumnsMap;
        return Object.keys(row).every((key) => {
          const column = columnsMap[key];
          if (column) {
            const err_msg = column.validate && column.validate(row[key]);
            if (err_msg) return false;
            if (column.outType)
              row[key] = outFormatter(column.outType, row[key]);
          }
          return true;
        });
      });
      if (!temp_rows.length) return;
      this.$emit(Events.Operate, "create", temp_rows);
    },
    fixScrollEmptyBug() {
      let scrollTimeout;
      console.log(this);
      const handleScroll = (event) => {
        // 清除旧的定时器
        if (scrollTimeout) {
          clearTimeout(scrollTimeout);
        }

        // 设置新的定时器
        scrollTimeout = setTimeout(() => {
          console.log("滚动已停止,触发滚动键");

          // 在这里触发滚动键
          // 例如:模拟向下箭头键的按下
          const keyboardEvent = new KeyboardEvent("keydown", {
            key: "ArrowDown",
            code: "ArrowDown",
          });
          document.dispatchEvent(keyboardEvent);
        }, 1050);
      };

      // 监听滚动事件
      document
        .querySelector(".scroller")
        .addEventListener("scroll", handleScroll);
    },
  },
});

var TableUtils = {
  VirtualTable,
  createTextFormatter,
  numberValidate,
  TableCellComponent,
};
// index.html
<link
  rel="stylesheet"
  href="https://cdn.jsdelivr.net/npm/vue3-virtual-scroller/dist/vue3-virtual-scroller.css"
/>
<link rel="stylesheet" href="./element-plus.min.css" />
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://unpkg.com/[email protected]/dist/index.full.js"></script>
<script src="https://unpkg.com/@element-plus/[email protected]/dist/index.iife.min.js"></script>

<script src="https://unpkg.com/[email protected]/lodash.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/vue3-virtual-scroller.min.js"></script>
<link rel="stylesheet" href="./big-table.css" />
<script src="./table_utils.js"></script>
<div id="app" style="width: 351px">
  <virtual-table
    :table-data="tableData"
    :columns="columns"
    :max-table-height="maxTableHeight"
    expand-column-key="name"
    @cell-change="handleCellChange"
    @operate="handlerOperation"
    :addition="isEditable"
  >
  </virtual-table>
</div>

<script>
  {
    class Element {
      static is_valid_symbol(element) {
        return element === "C";
      }
    }
    const { TableCellComponent, VirtualTable, numberValidate } = TableUtils;
    const { createApp } = Vue;
    const elements = ["Ca", "S", "O"];
    const tableData = [];
    const site_length = 1000000;
    elements.forEach((element, index) => {
      let i = 0;
      const children = new Array(site_length);
      const row = {
        children,
        is_expand: false,
        _editable: false,
        id: element,
        name: element,
        a: "-",
        b: "-",
        c: "-",
        occupancy: "-",
      };
      tableData.push(row);
      const id_offset = site_length * index;
      while (i < site_length) {
        const name = `${element}${i}`;
        children[i++] = {
          id: name,
          name,
          a: 1,
          b: 2,
          c: 3,
          occupancy: 0.5,
          _editable: true,
        };
      }
    });
    const isEditable = true;
    const isPeriodicity = true;
    const app = createApp({
      components: {
        VirtualTable,
      },
      data() {
        return {
          tableData,
          maxTableHeight: 506,
          columns: this.initColumn(),
          isEditable,
        };
      },

      methods: {
        translate(value) {
          return value;
        },
        initColumn() {
          const isCoordsEditable = true;
          const _numberValidate = numberValidate;
          const columns = [
            {
              title: "原子",
              width: "80",
              dataKey: "name",
              required: true,
              components: [
                TableCellComponent.ExpandText,
                TableCellComponent.Text,
                TableCellComponent.Input,
              ],
              validate: (value) => {
                const res = Element.is_valid_symbol(value);
                if (!res) return this.translate("unknown element symbol");
                else return "";
              },
            },
            {
              title: "a",
              width: "60",
              dataKey: "a",
              outType: "number",
              required: true,
              textFormatter: createTextFormatter(4),
              components: [
                TableCellComponent.Text,
                TableCellComponent.EditableText,
                TableCellComponent.Input,
              ],
              validate: _numberValidate,
            },
            {
              title: "b",
              width: "60",
              dataKey: "b",
              outType: "number",
              required: true,
              textFormatter: createTextFormatter(4),
              components: [
                TableCellComponent.Text,
                TableCellComponent.EditableText,
                TableCellComponent.Input,
              ],
              validate: _numberValidate,
            },
            {
              title: "c",
              width: "60px",
              dataKey: "c",
              outType: "number",
              required: true,
              textFormatter: createTextFormatter(4),
              components: [
                TableCellComponent.Text,
                TableCellComponent.EditableText,
                TableCellComponent.Input,
              ],
              validate: _numberValidate,
            },
          ];
          if (isPeriodicity) {
            columns.push({
              key: "occupancy",
              dataKey: "occupancy",
              title: "占比",
              width: "40",
              outType: "number",
              required: true,
              textFormatter: createTextFormatter(2),
              components: [
                TableCellComponent.Text,
                TableCellComponent.EditableText,
                TableCellComponent.Input,
              ],
              validate: (value) => {
                let res = _numberValidate(value);
                if (res) return res;
                else {
                  if (value <= 0 || value > 1) {
                    res = this.translate("occupancy should be within (0, 1]");
                  } else res = "";
                  return res;
                }
              },
            });
          }
          if (isEditable) {
            columns.push({
              key: "operate",
              title: "",
              width: "20",
              component: TableCellComponent.DeleteIcon,
            });
          }
          return columns;
        },
        handleCellChange() {},
        handlerOperation() {},
      },
    });

    app.mount("#app");
  }
</script>
// big-table.css
:root {
  --table-border-color: rgba(102, 120, 208, 0.5);
  --border-color: var(--color-main);
  --white: #fff;
  --balck: #fff;
  --color-main: #6678d0;
  --font-color: #606266;
  width: 100%;
  color: var(--font-color);
  font-family: "Microsoft YaHei", "Lucida Grande", "Segoe UI", "Ubuntu",
    "Cantarell", sans-serif;
  font-size: 12px;
  font-weight: normal;
}

.scroller {
  border: 1px solid #ddd;
}

th,
td {
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
  text-align: center;
  vertical-align: top;
  height: 22px;
}

.fixed-header {
  margin-bottom: -1px; /* 避免双重边框 */
}

.scroller-container {
  overflow-y: auto;
}

.addition-button {
  margin-top: 2px;
  display: flex;
  justify-content: center;
}
.addition-button .el-button {
  height: 14px;
  padding-bottom: 2px;
  border: none;
  font-size: 12px;
  padding: 0px 8px;
  line-height: 12px;
  background: var(--color-main);
  box-shadow: 0px 2px 6px 0px var(--color-main);
  color: var(--color-white);
  z-index: 1;
}

.delete_button.el-button.el-button--small.is-circle {
  width: 12px;
  height: 12px;
}

.vue-recycle-scroller {
  border: none;
  /* border-bottom: 1px solid var(--table-border-color); */
}

.vue-recycle-scroller__item-view + .vue-recycle-scroller__item-view {
  border-top: 1px solid var(--table-border-color);
}

.vue-recycle-scroller__item-wrapper td + td,
.fixed-header th + th {
  border-left: 1px solid var(--table-border-color);
}

.virtual-table .addtion-table td + td {
  border-left: 1px solid var(--table-border-color);
}

.virtual-table .addtion-table tr {
  border-top: 1px solid var(--table-border-color);
}

.fixed-header {
  border-bottom: 1px solid var(--table-border-color);
}

.virtual-table table {
  border-collapse: collapse;
}

.virtual-table .el-input__inner {
  border: none;
}
.virtual-table .el-input__wrapper.is-focus .el-input__inner {
  outline: none;
}

.virtual-table input.el-input__inner {
  color: var(--font-color);
  font-family: "Microsoft YaHei", "Lucida Grande", "Segoe UI", "Ubuntu",
    "Cantarell", sans-serif;
  font-size: 12px;
  font-weight: normal;
}

.expand-icon {
  display: flex;
}

.expand-icon .expand-icon-box {
  display: inline-block;
  position: relative;
  width: 14px;
  height: 14px;
  border-radius: 50%;
  margin-right: 2px;
  margin-top: 2px;
}

.expand-icon .expand-icon-label {
  display: inline-block;
  width: fit-content;
}

.expand-icon .expand-icon-box::before {
  position: absolute;
  content: " ";
  width: 6px;
  height: 6px;
  border-top: 0.5px solid var(--font-color);
  border-left: 0.5px solid var(--font-color);
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%) rotate(-225deg);
  transition: all 0.3s;
}

.expand-icon .expand.expand-icon-box::before {
  transform: translate(-50%, -50%) rotate(-135deg);
}

.el-popper.property-error-popper {
  opacity: 0.7;
  border: 1px solid red;
  color: red;
}

.el-popper[data-popper-placement^="bottom"].property-error-popper
  .el-popper__arrow::before {
  border-top-color: red;
  border-left-color: red;
}

.is-error .el-input__wrapper {
  box-shadow: 0 0 0 1px var(--el-color-danger) inset;
}

.virtual-table .el-input--small {
  height: 18px;
}

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.