+3

Giải quyết vấn đề search tô đậm, jump next prev trong khung chat giống skype

Vấn đề.

Vừa rồi mình có gặp một task khá đau đầu về search tô đậm text + jump next and prev với button giống như khung chat của skype. Cuối cùng mình cũng tìm được giải pháp, tuy chưa hoàn thiện, nhưng có thể coi là chấp nhận được nên share mọi người ạ.

Bối cảnh.

  • Mình cần có 1 khung chat, có thể lazy load dùng scroll, nhưng đồng thời cũng có button jump next và prev, cái khó là button jump next và prev phải làm cho khung chat nhảy tới các kết quả kế cạnh, dù cho kết quả đó chưa được load ra (vì nó phải mapping với kết quả search và đôi khi tin nhắn đó từ cách đây 1 tháng 😄).
  • Vấn đề thì mình tìm kiếm trên server, ở đây mình dùng elasticsearch, ES có khả năng tô đậm kết quả, tuy nhiên lại tô đậm tất cả kết quả mapping chứ ko thể jump next hay prev hay current là cái nào cả, vì vậy trong trường hợp này ko dùng được, nên mình quyết áp dụng cả server và client để hoàn thiện task này Mình đã tìm thấy 1 thư viện js giúp mình làm task này là Markjs giúp mình search và tô đậm, jump next và previous trong một đoạn text nào đó.

Giải quyết.

  • Việc đầu tiên mình sẽ tích lazy load theo scroll sử dùng jquery waypoint: Waypoint, thư viện này khá ngon cho việc phát hiện offset của scroll nhằm detect event cho việc load thêm. Code cơ bản là thế này (mình có giải thích 1 phần trong code):
var waypoints = function() {
    // chatLogFirst là element trên cùng khung chat, nhằm giúp waypoint detect scroll là đã tới Top rồi.
    // chatLogLast là element dưới cùng khung chat, nhằm giúp waypoint detect scroll là đã tới Bottom rồi.
            var $chatLogFirst = that.$modal.find('.in-search > .chat-log[data-id="'+val[0].id+'"]');
            var $chatLogLast = that.$modal.find('.in-search > .chat-log[data-id="'+val[val.length-1].id+'"]');

            console.log('.in-search > .chat-log[data-id="'+val[0].id+'"]');
            
            // Trước khi setup event wayoint mới, ta cần remove các event waypoint cũ, nhằm tránh trùng lặp.
            _.forEach(that.arrWayPoint, function(value) {
                value.disable();
            });

            var waypointUp = new Waypoint({
                element: $chatLogFirst[0],
                handler: function(direction) {
                    console.log('hit:', direction, that.shouldLoadMoreUp);

                    if (direction == 'down' || !that.shouldLoadMoreUp) return;
                    waypointUp.disable();
                    that.previousIndex = val[0].id;
                    that.$modal.find('.in-search').scrollTo(0, 0);
                    
                    // Nếu đã lên trên cùng thì load các message cũ ra nhé.
                    axios.get('/admin/api/history/load-more/'+that.activeThread.id, {
                        params: {
                            lastMessageId: val[0].id,
                            comparison: '<'
                        }
                    }).then(function(res) {
                        if (res.status == 200) {
                            if (_.isEmpty(res.data.data)) {
                                that.shouldLoadMoreUp = false;
                                that.$modal.find('.in-search #load-prev').remove();
                            }
                            _.forEach(res.data.data, function(value) {
                                that.messages.unshift(value);
                            });
                        }
                    });
                },
                context: that.$modal.find('#context-response')[0]
            });
            var waypointDown = new Waypoint({
                element: $chatLogLast[0],
                handler: function(direction) {
                    console.log('hit last:', direction, that.shouldLoadMoreDown);

                    if (direction == 'up' || !that.shouldLoadMoreDown) return;
                    waypointDown.disable();
                    that.nextIndex = val[val.length-1].id;

                    axios.get('/admin/api/history/load-more/'+that.activeThread.id, {
                        params: {
                            lastMessageId: val[val.length-1].id,
                            comparison: '>'
                        }
                    }).then(function(res) {
                        if (res.status == 200) {
                            if (_.isEmpty(res.data.data)) {
                                that.shouldLoadMoreDown = false;
                                that.$modal.find('.in-search #load-next').remove();
                            }
                            _.forEach(res.data.data, function(value) {
                                that.messages.push(value);
                            });
                        }
                    });
                },
                offset: '100%',
                context: that.$modal.find('#context-response')[0]
            });

            that.arrWayPoint.push(waypointDown);
            that.arrWayPoint.push(waypointUp);
        }
  • Sau khi có kết quả search thì phần tiếp theo là tô đậm kết quả search, mặc định mình sẽ tô đậm trên text của message mới nhất (nó sẽ có màu cam), các kết quả còn lại mình sẽ tô màu vàng. Các bạn để ý biến: this.currentIndex nó lưu trữ số thứ tự của kết quả hiện tại (có màu cam), nếu nó bằng 0 tức là current đang là đoạn text search trong message cũ nhất.
search() {
            var that = this;
            this.$content.unmark({
                done: function() {
                    that.$content.mark(that.text, {
                        separateWordSearch: true,
                        done: function() {
                            that.$results = that.$content.find("mark");
                            if (that.$results.length != 0) {
                                that.totalOldMark = that.$results.length;
                                if (that.direction == '<') {
                                    that.currentIndex = that.$results.length - 1
                                } else {
                                    that.currentIndex = 0;
                                }
                                that.jumpTo();
                            }
                        }
                    });
                }
            });
        },
        jumpTo() {
            if (this.$results.length) {
                var position,
                    $current = this.$results.eq(this.currentIndex);

                this.$results.removeClass(this.currentClass);
                if ($current.length) {
                    $current.addClass(this.currentClass);
                    var offset = 0;
                    this.$modal.find('.in-search').find('.chat-log').each(function(index, elem) {
                        if ($(elem).is($current.parents('.chat-log'))) {
                            return false;
                        } else {
                            offset += $(elem).height();
                        }
                    });
                    this.$content.scrollTo(offset);
                }
            }
        }

    },
  • Và đây là phần quan trọng nhất, xử lí cho button prev và next, flow ở đây là, nếu đây là button prevBtn thì giảm index xuống để nó có thể tô kết quả phía trước, nhưng nếu sau đó currentIndex = -1 tức là trên màn hình hiển thị không còn kết quả search nữa (that.currentIndex < 0) thì lúc đó chúng ta sẽ tìm kiếm trên server để tìm nhiều kết qủa hơn trước khi mình tô màu thêm
$nextBtn.add($prevBtn).on("click", function() {
            if (that.$results.length) {
                that.currentIndex += $(this).is($prevBtn) ? -1 : 1;
                that.direction = $(this).is($prevBtn) ? '<' : '>';
                if (that.currentIndex < 0) {
                    that.currentIndex += 1;
                    axios.get('/admin/api/history/search/'+that.activeThread.id, {
                        params: {
                            textSearch: that.text,
                            lastMessageId: that.messages[0].id,
                            comparison: '<'
                        }
                    }).then(function(res) {
                        if (res.status == 200 && !_.isEmpty(res.data.data)) {
                            that.messages = res.data.data;
                            Vue.nextTick(function() {
                                that.search();
                            });
                        }
                    });
                } else if (that.currentIndex > that.$results.length - 1) {
                    that.currentIndex -= 1;
                    axios.get('/admin/api/history/search/'+that.activeThread.id, {
                        params: {
                            textSearch: that.text,
                            lastMessageId: that.messages[that.messages.length-1].id,
                            comparison: '>'
                        }
                    }).then(function(res) {
                        if (res.status == 200 && !_.isEmpty(res.data.data)) {
                            that.messages = res.data.data;
                            Vue.nextTick(function() {
                                that.search();
                            });
                        }
                    });
                } else {
                    that.jumpTo();
                }
            }
        });

Kết quả.

result

  • Khá ngon lành, hy vọng các bạn có thể áp dụng được nếu gặp trường hợp này trong tương lai 😄

All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí