perf: improved scroll speed of result tables

This commit is contained in:
Fabio 2020-08-07 17:26:02 +02:00
parent 949f7add8f
commit bbde2bd994
3 changed files with 152 additions and 113 deletions

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="vscroll-holder"> <div :style="{'height': visibleHeight+'px'}" class="vscroll-holder">
<div <div
class="vscroll-spacer" class="vscroll-spacer"
:style="{ :style="{
@ -21,56 +21,53 @@
</template> </template>
<script> <script>
// credits: https://github.com/xrado 👼
export default { export default {
name: 'BaseVirtualScroll', name: 'BaseVirtualScroll',
props: { props: {
items: Array, items: Array,
itemHeight: Number itemHeight: Number,
visibleHeight: Number
}, },
data () { data () {
return { return {
topHeight: 0, topHeight: 0,
bottomHeight: 0, bottomHeight: 0,
visibleItems: [] visibleItems: [],
renderTimeout: null
}; };
}, },
mounted () { mounted () {
this._checkScrollPosition = this.checkScrollPosition.bind(this); this._checkScrollPosition = this.updateWindow.bind(this);
this.checkScrollPosition(); this.updateWindow();
this.$el.addEventListener('scroll', this._checkScrollPosition); this.$el.addEventListener('scroll', this._checkScrollPosition);
this.$el.addEventListener('wheel', this._checkScrollPosition);
}, },
beforeDestroy () { beforeDestroy () {
this.$el.removeEventListener('scroll', this._checkScrollPosition); this.$el.removeEventListener('scroll', this._checkScrollPosition);
this.$el.removeEventListener('wheel', this._checkScrollPosition);
}, },
methods: { methods: {
checkScrollPosition (e = {}) { checkScrollPosition () {
const el = this.$el;
// prevent parent scroll
if ((el.scrollTop === 0 && e.deltaY < 0) || (Math.abs(el.scrollTop - (el.scrollHeight - el.clientHeight)) <= 1 && e.deltaY > 0))
e.preventDefault();
this.updateWindow(e);
}, },
updateWindow (e) { updateWindow (e) {
const visibleItemsCount = Math.ceil(this.$el.clientHeight / this.itemHeight); const visibleItemsCount = Math.ceil(this.$el.clientHeight / this.itemHeight);
const totalScrollHeight = this.items.length * this.itemHeight; const totalScrollHeight = this.items.length * this.itemHeight;
const offset = 50;
const scrollTop = this.$el.scrollTop; const scrollTop = this.$el.scrollTop;
const offset = 5;
const firstVisibleIndex = Math.floor(scrollTop / this.itemHeight);
const lastVisibleIndex = firstVisibleIndex + visibleItemsCount;
const firstCutIndex = Math.max(firstVisibleIndex - offset, 0);
const lastCutIndex = lastVisibleIndex + offset;
this.visibleItems = this.items.slice(firstCutIndex, lastCutIndex); clearTimeout(this.renderTimeout);
this.topHeight = firstCutIndex * this.itemHeight; this.renderTimeout = setTimeout(() => {
this.bottomHeight = totalScrollHeight - this.visibleItems.length * this.itemHeight - this.topHeight; const firstVisibleIndex = Math.floor(scrollTop / this.itemHeight);
const lastVisibleIndex = firstVisibleIndex + visibleItemsCount;
const firstCutIndex = Math.max(firstVisibleIndex - offset, 0);
const lastCutIndex = lastVisibleIndex + offset;
this.visibleItems = this.items.slice(firstCutIndex, lastCutIndex);
this.topHeight = firstCutIndex * this.itemHeight;
this.bottomHeight = totalScrollHeight - this.visibleItems.length * this.itemHeight - this.topHeight;
}, 200);
} }
} }
}; };

View File

@ -11,9 +11,10 @@
v-if="results.rows" v-if="results.rows"
ref="resultTable" ref="resultTable"
:items="sortedResults" :items="sortedResults"
:item-height="25" :item-height="22"
class="vscroll" class="vscroll"
:style="{'height': resultsSize+'px'}" :style="{'height': resultsSize+'px'}"
:visible-height="resultsSize"
> >
<template slot-scope="{ items }"> <template slot-scope="{ items }">
<div class="table table-hover"> <div class="table table-hover">
@ -40,24 +41,17 @@
</div> </div>
</div> </div>
<div class="tbody"> <div class="tbody">
<div <WorkspaceQueryTableRow
v-for="row in items" v-for="row in items"
:key="row._id" :key="row._id"
:row="row"
:fields="fields"
class="tr" class="tr"
:class="{'selected': selectedRows.includes(row._id)}" :class="{'selected': selectedRows.includes(row._id)}"
@click="selectRow($event, row._id)" @selectRow="selectRow($event, row._id)"
> @updateField="updateField($event, row[primaryField.name])"
<WorkspaceQueryTableCell @contextmenu="contextMenu"
v-for="(col, cKey) in row" />
:key="cKey"
:content="col"
:field="cKey"
:precision="fieldPrecision(cKey)"
:type="fieldType(cKey)"
@updateField="updateField($event, row[primaryField.name])"
@contextmenu="contextMenu($event, {id: row._id, field: cKey})"
/>
</div>
</div> </div>
</div> </div>
</template> </template>
@ -68,7 +62,7 @@
<script> <script>
import { uidGen } from 'common/libs/uidGen'; import { uidGen } from 'common/libs/uidGen';
import BaseVirtualScroll from '@/components/BaseVirtualScroll'; import BaseVirtualScroll from '@/components/BaseVirtualScroll';
import WorkspaceQueryTableCell from '@/components/WorkspaceQueryTableCell'; import WorkspaceQueryTableRow from '@/components/WorkspaceQueryTableRow';
import TableContext from '@/components/WorkspaceQueryTableContext'; import TableContext from '@/components/WorkspaceQueryTableContext';
import { mapActions } from 'vuex'; import { mapActions } from 'vuex';
@ -76,7 +70,7 @@ export default {
name: 'WorkspaceQueryTable', name: 'WorkspaceQueryTable',
components: { components: {
BaseVirtualScroll, BaseVirtualScroll,
WorkspaceQueryTableCell, WorkspaceQueryTableRow,
TableContext TableContext
}, },
props: { props: {

View File

@ -1,38 +1,43 @@
<template> <template>
<div <div class="tr" @click="selectRow($event, row._id)">
v-if="field !== '_id'" <div
ref="cell" v-for="(col, cKey) in row"
class="td p-0" :key="cKey"
tabindex="0" class="td p-0"
@contextmenu.prevent="$emit('contextmenu', $event)" tabindex="0"
> @contextmenu.prevent="$emit('contextmenu', $event, {id: row._id, field: cKey})"
<span @updateField="updateField($event, row[primaryField.name])"
v-if="!isInlineEditor" >
class="cell-content px-2" <template v-if="cKey !== '_id'">
:class="`${isNull(content)} type-${type}`" <span
@dblclick="editON" v-if="!isInlineEditor[cKey]"
>{{ content | typeFormat(type, precision) | cutText }}</span> class="cell-content px-2"
<template v-else> :class="`${isNull(col)} type-${fieldType(cKey)}`"
<input @dblclick="editON($event, col, cKey)"
v-if="inputProps.mask" >{{ col | typeFormat(fieldType(cKey), fieldPrecision(cKey)) | cutText }}</span>
ref="editField" <template v-else>
v-model="localContent" <input
v-mask="inputProps.mask" v-if="inputProps.mask"
:type="inputProps.type" ref="editField"
autofocus v-model="editingContent"
class="editable-field px-2" v-mask="inputProps.mask"
@blur="editOFF" :type="inputProps.type"
> autofocus
<input class="editable-field px-2"
v-else @blur="editOFF"
ref="editField" >
v-model="localContent" <input
:type="inputProps.type" v-else
autofocus ref="editField"
class="editable-field px-2" v-model="editingContent"
@blur="editOFF" :type="inputProps.type"
> autofocus
</template> class="editable-field px-2"
@blur="editOFF"
>
</template>
</template>
</div>
<ConfirmModal <ConfirmModal
v-if="isTextareaEditor" v-if="isTextareaEditor"
:confirm-text="$t('word.update')" :confirm-text="$t('word.update')"
@ -41,19 +46,19 @@
@hide="hideEditorModal" @hide="hideEditorModal"
> >
<template :slot="'header'"> <template :slot="'header'">
{{ $t('word.edit') }} "{{ field }}" {{ $t('word.edit') }} "{{ editingField }}"
</template> </template>
<div :slot="'body'"> <div :slot="'body'">
<div class="mb-2"> <div class="mb-2">
<div> <div>
<textarea <textarea
v-model="localContent" v-model="editingContent"
class="form-input textarea-editor" class="form-input textarea-editor"
/> />
</div> </div>
<div class="editor-field-info"> <div class="editor-field-info">
<div><b>{{ $t('word.size') }}</b>: {{ localContent.length }}</div> <div><b>{{ $t('word.size') }}</b>: {{ editingContent.length }}</div>
<div><b>{{ $t('word.type') }}</b>: {{ type.toUpperCase() }}</div> <div><b>{{ $t('word.type') }}</b>: {{ editingType.toUpperCase() }}</div>
</div> </div>
</div> </div>
</div> </div>
@ -65,7 +70,7 @@
@hide="hideEditorModal" @hide="hideEditorModal"
> >
<template :slot="'header'"> <template :slot="'header'">
{{ $t('word.edit') }} "{{ field }}" {{ $t('word.edit') }} "{{ editingField }}"
</template> </template>
<div :slot="'body'"> <div :slot="'body'">
<div class="mb-2"> <div class="mb-2">
@ -73,7 +78,7 @@
<div v-if="contentInfo.size"> <div v-if="contentInfo.size">
<img <img
v-if="isImage" v-if="isImage"
:src="`data:${contentInfo.mime};base64, ${bufferToBase64(localContent)}`" :src="`data:${contentInfo.mime};base64, ${bufferToBase64(editingContent)}`"
class="img-responsive p-centered bg-checkered" class="img-responsive p-centered bg-checkered"
> >
<div v-else class="text-center"> <div v-else class="text-center">
@ -93,10 +98,10 @@
</transition> </transition>
<div class="editor-field-info"> <div class="editor-field-info">
<div> <div>
<b>{{ $t('word.size') }}</b>: {{ localContent.length | formatBytes }}<br> <b>{{ $t('word.size') }}</b>: {{ editingContent.length | formatBytes }}<br>
<b>{{ $t('word.mimeType') }}</b>: {{ contentInfo.mime }} <b>{{ $t('word.mimeType') }}</b>: {{ contentInfo.mime }}
</div> </div>
<div><b>{{ $t('word.type') }}</b>: {{ type.toUpperCase() }}</div> <div><b>{{ $t('word.type') }}</b>: {{ editingType.toUpperCase() }}</div>
</div> </div>
<div class="mt-3"> <div class="mt-3">
<label>{{ $t('message.uploadFile') }}</label> <label>{{ $t('message.uploadFile') }}</label>
@ -123,10 +128,13 @@ import { mask } from 'vue-the-mask';
import ConfirmModal from '@/components/BaseConfirmModal'; import ConfirmModal from '@/components/BaseConfirmModal';
export default { export default {
name: 'WorkspaceQueryTableCell', name: 'WorkspaceQueryTableRow',
components: { components: {
ConfirmModal ConfirmModal
}, },
directives: {
mask
},
filters: { filters: {
formatBytes, formatBytes,
cutText (val) { cutText (val) {
@ -163,22 +171,20 @@ export default {
return val; return val;
} }
}, },
directives: {
mask
},
props: { props: {
type: String, row: Object,
field: String, fields: Array
precision: [Number, null],
content: [String, Number, Object, Date, Uint8Array]
}, },
data () { data () {
return { return {
isInlineEditor: false, isInlineEditor: {},
isTextareaEditor: false, isTextareaEditor: false,
isBlobEditor: false, isBlobEditor: false,
willBeDeleted: false, willBeDeleted: false,
localContent: null, originalContent: null,
editingContent: null,
editingType: null,
editingField: null,
contentInfo: { contentInfo: {
ext: '', ext: '',
mime: '', mime: '',
@ -220,35 +226,61 @@ export default {
return ['gif', 'jpg', 'png', 'bmp', 'ico', 'tif'].includes(this.contentInfo.ext); return ['gif', 'jpg', 'png', 'bmp', 'ico', 'tif'].includes(this.contentInfo.ext);
} }
}, },
created () {
this.fields.forEach(field => {
this.isInlineEditor[field.name] = false;
});
},
methods: { methods: {
fieldType (cKey) {
let type = 'unknown';
const field = this.fields.filter(field => field.name === cKey)[0];
if (field)
type = field.type;
return type;
},
fieldPrecision (cKey) {
let length = 0;
const field = this.fields.filter(field => field.name === cKey)[0];
if (field)
length = field.precision;
return length;
},
isNull (value) { isNull (value) {
return value === null ? ' is-null' : ''; return value === null ? ' is-null' : '';
}, },
bufferToBase64 (val) { bufferToBase64 (val) {
return bufferToBase64(val); return bufferToBase64(val);
}, },
editON () { editON (event, content, field) {
if (LONG_TEXT.includes(this.type)) { const type = this.fieldType(field);
this.originalContent = content;
this.editingType = type;
this.editingField = field;
if (LONG_TEXT.includes(type)) {
this.isTextareaEditor = true; this.isTextareaEditor = true;
this.localContent = this.$options.filters.typeFormat(this.content, this.type); this.editingContent = this.$options.filters.typeFormat(this.originalContent, type);
return; return;
} }
if (BLOB.includes(this.type)) { if (BLOB.includes(type)) {
this.isBlobEditor = true; this.isBlobEditor = true;
this.localContent = this.content ? this.content : ''; this.editingContent = this.originalContent || '';
this.fileToUpload = null; this.fileToUpload = null;
this.willBeDeleted = false; this.willBeDeleted = false;
if (this.content !== null) { if (this.originalContent !== null) {
const buff = Buffer.from(this.localContent); const buff = Buffer.from(this.editingContent);
if (buff.length) { if (buff.length) {
const hex = buff.toString('hex').substring(0, 8).toUpperCase(); const hex = buff.toString('hex').substring(0, 8).toUpperCase();
const { ext, mime } = mimeFromHex(hex); const { ext, mime } = mimeFromHex(hex);
this.contentInfo = { this.contentInfo = {
ext, ext,
mime, mime,
size: this.localContent.length size: this.editingContent.length
}; };
} }
} }
@ -256,20 +288,24 @@ export default {
} }
// Inline editable fields // Inline editable fields
this.localContent = this.$options.filters.typeFormat(this.content, this.type); this.editingContent = this.$options.filters.typeFormat(this.originalContent, type);
this.$nextTick(() => { // Focus on input this.$nextTick(() => { // Focus on input
this.$refs.cell.blur(); event.target.blur();
this.$nextTick(() => this.$refs.editField.focus()); this.$nextTick(() => document.querySelector('.editable-field').focus());
}); });
this.isInlineEditor = true;
const obj = {
[field]: true
};
this.isInlineEditor = { ...this.isInlineEditor, ...obj };
}, },
editOFF () { editOFF () {
this.isInlineEditor = false; this.isInlineEditor[this.editingField] = false;
let content; let content;
if (!['blob', 'mediumblob', 'longblob'].includes(this.type)) { if (!['blob', 'mediumblob', 'longblob'].includes(this.editingType)) {
if (this.localContent === this.$options.filters.typeFormat(this.content, this.type)) return;// If not changed if (this.editingContent === this.$options.filters.typeFormat(this.originalContent, this.editingType)) return;// If not changed
content = this.localContent; content = this.editingContent;
} }
else { // Handle file upload else { // Handle file upload
if (this.willBeDeleted) { if (this.willBeDeleted) {
@ -283,10 +319,13 @@ export default {
} }
this.$emit('updateField', { this.$emit('updateField', {
field: this.field, field: this.editingField,
type: this.type, type: this.editingType,
content content
}); });
this.editingType = null;
this.editingField = null;
}, },
hideEditorModal () { hideEditorModal () {
this.isTextareaEditor = false; this.isTextareaEditor = false;
@ -295,7 +334,7 @@ export default {
downloadFile () { downloadFile () {
const downloadLink = document.createElement('a'); const downloadLink = document.createElement('a');
downloadLink.href = `data:${this.contentInfo.mime};base64, ${bufferToBase64(this.localContent)}`; downloadLink.href = `data:${this.contentInfo.mime};base64, ${bufferToBase64(this.editingContent)}`;
downloadLink.setAttribute('download', `${this.field}.${this.contentInfo.ext}`); downloadLink.setAttribute('download', `${this.field}.${this.contentInfo.ext}`);
document.body.appendChild(downloadLink); document.body.appendChild(downloadLink);
@ -310,13 +349,22 @@ export default {
this.willBeDeleted = false; this.willBeDeleted = false;
}, },
prepareToDelete () { prepareToDelete () {
this.localContent = ''; this.editingContent = '';
this.contentInfo = { this.contentInfo = {
ext: '', ext: '',
mime: '', mime: '',
size: null size: null
}; };
this.willBeDeleted = true; this.willBeDeleted = true;
},
updateField (event, id) {
this.$emit('updateField', event, id);
},
contextMenu (event, cell) {
this.$emit('updateField', event, cell);
},
selectRow (event, row) {
this.$emit('selectRow', event, row);
} }
} }
}; };