How does Twitter implement its Tweet Box?

后端 未结 3 1351
轮回少年
轮回少年 2020-12-30 07:59

I\'m trying to implement something like Twitter\'s tweet box, specifically:

  • Automatically highlights text in a red background when the overall length exceeds 1
3条回答
  •  夕颜
    夕颜 (楼主)
    2020-12-30 08:49

    Turns out this is really not an easy thing to do. I've been struggling with it for the past few days, and I'm not very close to a solution.

    Your best drop-in solution currently is the At.js library, which is still being maintained, but definitely isn't perfect. One of the examples shows how you can kind of do a text highlight.

    The most annoying part of this problem is that Twitter has a beautiful solution that seemingly works perfectly staring us right in the face. I took some time investigating the way they implement their "tweet box," and it's definitely not trivial. It looks like they're doing almost everything manually, including emulating undo/redo functionality, intercepting copy/pastes, providing custom code for IE/W3C, custom-coding Mac/PC, and more. They use a contenteditable div, which is in-and-of-itself problematic due to differences in browser implementations. It's pretty impressive, actually.

    Here's the most relevant (obfuscated, unfortunately) code, taken from Twitter's boot JavaScript file (found by inspecting the header of the logged-in Twitter homepage). I didn't want to directly copy and paste the link, in case it's personalized to my Twitter account.

    define("app/utils/html_text", ["module", "require", "exports"], function(module, require, exports) {
        function isTextNode(a) {
            return a.nodeType == 3 || a.nodeType == 4
        }
    
        function isElementNode(a) {
            return a.nodeType == 1
        }
    
        function isBrNode(a) {
            return isElementNode(a) && a.nodeName.toLowerCase() == "br"
        }
    
        function isOutsideContainer(a, b) {
            while (a !== b) {
                if (!a) return !0;
                a = a.parentNode
            }
        }
        var useW3CRange = window.getSelection,
            useMsftTextRange = !useW3CRange && document.selection,
            useIeHtmlFix = navigator.appName == "Microsoft Internet Explorer",
            NBSP_REGEX = /[\xa0\n\t]/g,
            CRLF_REGEX = /\r\n/g,
            LINES_REGEX = /(.*?)\n/g,
            SP_LEADING_OR_FOLLOWING_CLOSE_TAG_OR_PRECEDING_A_SP_REGEX = /^ |(<\/[^>]+>) | (?= )/g,
            SP_LEADING_OR_TRAILING_OR_FOLLOWING_A_SP_REGEX = /^ | $|( ) /g,
            MAX_OFFSET = Number.MAX_VALUE,
            htmlText = function(a, b) {
                function c(a, c) {
                    function h(a) {
                        var i = d.length;
                        if (isTextNode(a)) {
                            var j = a.nodeValue.replace(NBSP_REGEX, " "),
                                k = j.length;
                            k && (d += j, e = !0), c(a, !0, 0, i, i + k)
                        } else if (isElementNode(a)) {
                            c(a, !1, 0, i, i);
                            if (isBrNode(a)) a == f ? g = !0 : (d += "\n", e = !1);
                            else {
                                var l = a.currentStyle || window.getComputedStyle(a, ""),
                                    m = l.display == "block";
                                m && b.msie && (e = !0);
                                for (var n = a.firstChild, o = 1; n; n = n.nextSibling, o++) {
                                    h(n);
                                    if (g) return;
                                    i = d.length, c(a, !1, o, i, i)
                                }
                                g || a == f ? g = !0 : m && e && (d += "\n", e = !1)
                            }
                        }
                    }
                    var d = "",
                        e, f, g;
                    for (var i = a; i && isElementNode(i); i = i.lastChild) f = i;
                    return h(a), d
                }
    
                function d(a, b) {
                    var d = null,
                        e = b.length - 1;
                    if (useW3CRange) {
                        var f = b.map(function() {
                                return {}
                            }),
                            g;
                        c(a, function(a, c, d, h, i) {
                            g || f.forEach(function(f, j) {
                                var k = b[j];
                                h <= k && !isBrNode(a) && (f.node = a, f.offset = c ? Math.min(k, i) - h : d, g = c && j == e && i >= k)
                            })
                        }), f[0].node && f[e].node && (d = document.createRange(), d.setStart(f[0].node, f[0].offset), d.setEnd(f[e].node, f[e].offset))
                    } else if (useMsftTextRange) {
                        var h = document.body.createTextRange();
                        h.moveToElementText(a), d = h.duplicate();
                        if (b[0] == MAX_OFFSET) d.setEndPoint("StartToEnd", h);
                        else {
                            d.move("character", b[0]);
                            var i = e && b[1] - b[0];
                            i > 0 && d.moveEnd("character", i), h.inRange(d) || d.setEndPoint("EndToEnd", h)
                        }
                    }
                    return d
                }
    
                function e() {
                    return document.body.contains(a)
                }
    
                function f(b) {
                    a.innerHTML = b;
                    if (useIeHtmlFix)
                        for (var c = a.firstChild; c; c = c.nextSibling) isElementNode(c) && c.nodeName.toLowerCase() == "p" && c.innerHTML == "" && (c.innerText = "")
                }
    
                function g(a, b) {
                    return a.map(function(a) {
                        return Math.min(a, b.length)
                    })
                }
    
                function h() {
                    var b = getSelection();
                    if (b.rangeCount !== 1) return null;
                    var d = b.getRangeAt(0);
                    if (isOutsideContainer(d.commonAncestorContainer, a)) return null;
                    var e = [{
                        node: d.startContainer,
                        offset: d.startOffset
                    }];
                    d.collapsed || e.push({
                        node: d.endContainer,
                        offset: d.endOffset
                    });
                    var f = e.map(function() {
                            return MAX_OFFSET
                        }),
                        h = c(a, function(a, b, c, d) {
                            e.forEach(function(e, g) {
                                f[g] == MAX_OFFSET && a == e.node && (b || c == e.offset) && (f[g] = d + (b ? e.offset : 0))
                            })
                        });
                    return g(f, h)
                }
    
                function i() {
                    var b = document.selection.createRange();
                    if (isOutsideContainer(b.parentElement(), a)) return null;
                    var d = ["Start"];
                    b.compareEndPoints("StartToEnd", b) && d.push("End");
                    var e = d.map(function() {
                            return MAX_OFFSET
                        }),
                        f = document.body.createTextRange(),
                        h = c(a, function(c, g, h, i) {
                            function j(a, c) {
                                if (e[c] < MAX_OFFSET) return;
                                var d = f.compareEndPoints("StartTo" + a, b);
                                if (d > 0) return;
                                var g = f.compareEndPoints("EndTo" + a, b);
                                if (g < 0) return;
                                var h = f.duplicate();
                                h.setEndPoint("EndTo" + a, b), e[c] = i + h.text.length, c && !g && e[c]++
                            }!g && !h && c != a && (f.moveToElementText(c), d.forEach(j))
                        });
                    return g(e, h)
                }
                return {
                    getHtml: function() {
                        if (useIeHtmlFix) {
                            var b = "",
                                c = document.createElement("div");
                            for (var d = a.firstChild; d; d = d.nextSibling) isTextNode(d) ? (c.innerText = d.nodeValue, b += c.innerHTML) : b += d.outerHTML.replace(CRLF_REGEX, "");
                            return b
                        }
                        return a.innerHTML
                    },
                    setHtml: function(a) {
                        f(a)
                    },
                    getText: function() {
                        return c(a, function() {})
                    },
                    setTextWithMarkup: function(a) {
                        f((a + "\n").replace(LINES_REGEX, function(a, c) {
                            return b.mozilla || b.msie ? (c = c.replace(SP_LEADING_OR_FOLLOWING_CLOSE_TAG_OR_PRECEDING_A_SP_REGEX, "$1 "), b.mozilla ? c + "
    " : "

    " + c + "

    ") : (c = (c || "
    ").replace(SP_LEADING_OR_TRAILING_OR_FOLLOWING_A_SP_REGEX, "$1 "), b.opera ? "

    " + c + "

    " : "
    " + c + "
    ") })) }, getSelectionOffsets: function() { var a = null; return e() && (useW3CRange ? a = h() : useMsftTextRange && (a = i())), a }, setSelectionOffsets: function(b) { if (b && e()) { var c = d(a, b); if (c) if (useW3CRange) { var f = window.getSelection(); f.removeAllRanges(), f.addRange(c) } else useMsftTextRange && c.select() } }, emphasizeText: function(b) { var f = []; b && b.length > 1 && e() && (c(a, function(a, c, d, e, g) { if (c) { var h = Math.max(e, b[0]), i = Math.min(g, b[1]); i > h && f.push([h, i]) } }), f.forEach(function(b) { var c = d(a, b); c && (useW3CRange ? c.surroundContents(document.createElement("em")) : useMsftTextRange && c.execCommand("italic", !1, null)) })) } } }; module.exports = htmlText }); define("app/utils/tweet_helper", ["module", "require", "exports", "lib/twitter-text", "core/utils", "app/data/user_info"], function(module, require, exports) { var twitterText = require("lib/twitter-text"), utils = require("core/utils"), userInfo = require("app/data/user_info"), VALID_PROTOCOL_PREFIX_REGEX = /^https?:\/\//i, tweetHelper = { extractMentionsForReply: function(a, b) { var c = a.attr("data-screen-name"), d = a.attr("data-retweeter"), e = a.attr("data-mentions") ? a.attr("data-mentions").split(" ") : [], f = a.attr("data-tagged") ? a.attr("data-tagged").split(" ") : []; e = e.concat(f); var g = [c, b, d]; return e = e.filter(function(a) { return g.indexOf(a) < 0 }), d && d != c && d != b && e.unshift(d), (!e.length || c != b) && e.unshift(c), e }, linkify: function(a, b) { return b = utils.merge({ hashtagClass: "twitter-hashtag pretty-link", hashtagUrlBase: "/search?q=%23", symbolTag: "s", textWithSymbolTag: "b", cashtagClass: "twitter-cashtag pretty-link", cashtagUrlBase: "/search?q=%24", usernameClass: "twitter-atreply pretty-link", usernameUrlBase: "/", usernameIncludeSymbol: !0, listClass: "twitter-listname pretty-link", urlClass: "twitter-timeline-link", urlTarget: "_blank", suppressNoFollow: !0, htmlEscapeNonEntities: !0 }, b || {}), twitterText.autoLinkEntities(a, twitterText.extractEntitiesWithIndices(a), b) } }; module.exports = tweetHelper }); define("app/ui/compose/with_rich_editor", ["module", "require", "exports", "app/utils/file", "app/utils/html_text", "app/utils/tweet_helper", "lib/twitter-text"], function(module, require, exports) { function withRichEditor() { this.defaultAttrs({ richSelector: "div.rich-editor", linksSelector: "a", normalizerSelector: "div.rich-normalizer", $browser: $.browser }), this.linkify = function(a) { var b = { urlTarget: null, textWithSymbolTag: RENDER_URLS_AS_PRETTY_LINKS ? "b" : "", linkAttributeBlock: function(a, b) { var c = a.screenName || a.url; c && (this.urlAndMentionsCharCount += c.length + 2), delete b.title, delete b["data-screen-name"], b.dir = a.hashtag && this.shouldBeRTL(a.hashtag, 0) ? "rtl" : "ltr", b.role = "presentation" }.bind(this) }; return this.urlAndMentionsCharCount = 0, tweetHelper.linkify(a, b) }, this.around("setSelection", function(a, b) { b && this.setSelectionIfFocused(b) }), this.around("setCursorPosition", function(a, b) { b === undefined && (b = this.attr.cursorPosition), b === undefined && (b = MAX_OFFSET), this.setSelectionIfFocused([b]) }), this.around("detectUpdatedText", function(a, b, c) { var d = this.htmlRich.getHtml(), e = this.htmlRich.getSelectionOffsets() || [MAX_OFFSET], f = c !== undefined; if (d === this.prevHtml && e[0] === this.prevSelectionOffset && !b && !f) return; var g = f ? c : this.htmlRich.getText(), h = g.replace(INVALID_CHARS_REGEX, ""); (f || !(!d && !this.hasFocus() || this.$text.attr("data-in-composition"))) && this.reformatHtml(h, d, e, f); if (b || this.cleanedText != h || this.prevSelectionOffset != e[0]) this.prevSelectionOffset = e[0], this.updateCleanedTextAndOffset(h, e[0]) }), this.reformatHtml = function(a, b, c, d) { this.htmlNormalizer.setTextWithMarkup(this.linkify(a)), this.interceptDataImageInContent(); var e = this.shouldBeRTL(a, this.urlAndMentionsCharCount); this.$text.attr("dir", e ? "rtl" : "ltr"), this.$normalizer.find(e ? "[dir=rtl]" : "[dir=ltr]").removeAttr("dir"), RENDER_URLS_AS_PRETTY_LINKS && this.$normalizer.find(".twitter-timeline-link").wrapInner("").addClass("pretty-link"); var f = this.getMaxLengthOffset(a); f >= 0 && (this.htmlNormalizer.emphasizeText([f, MAX_OFFSET]), this.$normalizer.find("em").each(function() { this.innerHTML = this.innerHTML.replace(TRAILING_SINGLE_SPACE_REGEX, "Â ") })); var g = this.htmlNormalizer.getHtml(); if (g !== b) { var h = d && !this.isFocusing && this.hasFocus(); h && this.$text.addClass("fake-focus").blur(), this.htmlRich.setHtml(g), h && this.$text.focus().removeClass("fake-focus"), this.setSelectionIfFocused(c) } this.prevHtml = g }, this.interceptDataImageInContent = function() { if (!this.triggerGotImageData) return; this.$text.find("img").filter(function(a, b) { return b.src.match(/^data:/) }).first().each(function(a, b) { var c = file.getBlobFromDataUri(b.src); file.getFileData("pasted.png", c).then(this.triggerGotImageData.bind(this)) }.bind(this)) }, this.getMaxLengthOffset = function(a) { var b = this.getLength(a), c = this.attr.maxLength; if (b <= c) return -1; c += twitterText.getUnicodeTextLength(a) - b; var d = [{ indices: [c, c] }]; return twitterText.modifyIndicesFromUnicodeToUTF16(a, d), d[0].indices[0] }, this.setSelectionIfFocused = function(a) { this.hasFocus() ? (this.previousSelection = null, this.htmlRich.setSelectionOffsets(a)) : this.previousSelection = a }, this.selectPrevCharOnBackspace = function(a) { if (a.which == 8 && !a.ctrlKey) { var b = this.htmlRich.getSelectionOffsets(); b && b[0] != MAX_OFFSET && b.length == 1 && (b[0] ? this.setSelectionIfFocused([b[0] - 1, b[0]]) : this.stopEvent(a)) } }, this.emulateCommandArrow = function(a) { if (a.metaKey && !a.shiftKey && (a.which == 37 || a.which == 39)) { var b = a.which == 37; this.htmlRich.setSelectionOffsets([b ? 0 : MAX_OFFSET]), this.$text.scrollTop(b ? 0 : this.$text[0].scrollHeight), this.stopEvent(a) } }, this.stopEvent = function(a) { a.preventDefault(), a.stopPropagation() }, this.saveUndoStateDeferred = function(a) { if (a.type == "mousemove" && a.which != 1) return; setTimeout(function() { this.detectUpdatedText(), this.saveUndoState() }.bind(this), 0) }, this.clearUndoState = function() { this.undoHistory = [], this.undoIndex = -1 }, this.saveUndoState = function() { var a = this.htmlRich.getText(), b = this.htmlRich.getSelectionOffsets() || [a.length], c = this.undoHistory, d = c[this.undoIndex]; !d || d[0] !== a ? c.splice(++this.undoIndex, c.length, [a, b]) : d && (d[1] = b) }, this.isUndoKey = function(a) { return this.isMac ? a.which == 90 && a.metaKey && !a.shiftKey && !a.ctrlKey && !a.altKey : a.which == 90 && a.ctrlKey && !a.shiftKey && !a.altKey }, this.emulateUndo = function(a) { this.isUndoKey(a) && (this.stopEvent(a), this.saveUndoState(), this.undoIndex > 0 && this.setUndoState(this.undoHistory[--this.undoIndex])) }, this.isRedoKey = function(a) { return this.isMac ? a.which == 90 && a.metaKey && a.shiftKey && !a.ctrlKey && !a.altKey : this.isWin ? a.which == 89 && a.ctrlKey && !a.shiftKey && !a.altKey : a.which == 90 && a.shiftKey && a.ctrlKey && !a.altKey }, this.emulateRedo = function(a) { var b = this.undoHistory, c = this.undoIndex; c < b.length - 1 && this.htmlRich.getText() !== b[c][0] && b.splice(c + 1, b.length), this.isRedoKey(a) && (this.stopEvent(a), c < b.length - 1 && this.setUndoState(b[++this.undoIndex])) }, this.setUndoState = function(a) { this.detectUpdatedText(!1, a[0]), this.htmlRich.setSelectionOffsets(a[1]), this.trigger("uiHideAutocomplete") }, this.undoStateAfterCursorMovement = function(a) { a.which >= 33 && a.which <= 40 && this.saveUndoStateDeferred(a) }, this.handleKeyDown = function(a) { this.isIE && this.selectPrevCharOnBackspace(a), this.attr.$browser.mozilla && this.emulateCommandArrow(a), this.undoStateAfterCursorMovement(a), this.emulateUndo(a), this.emulateRedo(a) }, this.interceptPaste = function(a) { if (a.originalEvent && a.originalEvent.clipboardData) { var b = a.originalEvent.clipboardData; (this.interceptImagePaste(b) || this.interceptTextPaste(b)) && a.preventDefault() } }, this.interceptImagePaste = function(a) { return this.triggerGotImageData && a.items && a.items.length === 1 && a.items[0].kind === "file" && a.items[0].type.indexOf("image/") === 0 ? (file.getFileData("pasted.png", a.items[0].getAsFile()).then(this.triggerGotImageData.bind(this)), !0) : !1 }, this.interceptTextPaste = function(a) { var b = a.getData("text"); return b && document.execCommand("insertHTML", !1, $("
    ").text(b).html().replace(LINE_FEEDS_REGEX, "
    ")) ? !0 : !1 }, this.clearSelectionOnBlur = function() { window.getSelection && (this.previousSelection = this.htmlRich.getSelectionOffsets(), this.previousSelection && getSelection().removeAllRanges()) }, this.restoreSelectionOnFocus = function() { this.previousSelection ? setTimeout(function() { this.htmlRich.setSelectionOffsets(this.previousSelection), this.previousSelection = null }.bind(this), 0) : this.previousSelection = null }, this.setFocusingState = function() { this.isFocusing = !0, setTimeout(function() { this.isFocusing = !1 }.bind(this), 0) }, this.around("initTextNode", function(a) { this.isIE = this.attr.$browser.msie || navigator.userAgent.indexOf("Trident") > -1, this.$text = this.select("richSelector"), this.undoHistory = [ ["", [0]] ], this.undoIndex = 0, this.htmlRich = htmlText(this.$text[0], this.attr.$browser), this.$text.toggleClass("notie", !this.isIE), this.$normalizer = this.select("normalizerSelector"), this.htmlNormalizer = htmlText(this.$normalizer[0], this.attr.$browser); var b = navigator.platform; this.isMac = b.indexOf("Mac") != -1, this.isWin = b.indexOf("Win") != -1, this.on(this.$text, "click", { linksSelector: this.stopEvent }), this.on(this.$text, "focusin", this.setFocusingState), this.on(this.$text, "keydown", this.handleKeyDown), this.on(this.$text, "focusout", this.ignoreDuringFakeFocus(this.clearSelectionOnBlur)), this.on(this.$text, "focusin", this.ignoreDuringFakeFocus(this.restoreSelectionOnFocus)), this.on(this.$text, "focusin", this.ignoreDuringFakeFocus(this.saveUndoStateDeferred)), this.on(this.$text, "cut paste drop", this.saveUndoState), this.on(this.$text, "cut paste drop mousedown mousemove", this.saveUndoStateDeferred), this.on("uiSaveUndoState", this.saveUndoState), this.on("uiClearUndoState", this.clearUndoState), this.on(this.$text, "paste", this.interceptPaste), this.detectUpdatedText() }) } var file = require("app/utils/file"), htmlText = require("app/utils/html_text"), tweetHelper = require("app/utils/tweet_helper"), twitterText = require("lib/twitter-text"); module.exports = withRichEditor; var INVALID_CHARS_REGEX = /[\uFFFE\uFEFF\uFFFF\u200E\u200F\u202A-\u202E\x00-\x09\x0B\x0C\x0E-\x1F]/g, RENDER_URLS_AS_PRETTY_LINKS = $.browser.mozilla && parseInt($.browser.version, 10) < 2, TRAILING_SINGLE_SPACE_REGEX = / $/, LINE_FEEDS_REGEX = /\r\n|\n\r|\n/g, MAX_OFFSET = Number.MAX_VALUE }); define("app/ui/compose/tweet_box_manager", ["module", "require", "exports", "app/ui/compose/tweet_box", "app/ui/compose/dm_composer", "app/ui/geo_picker", "core/component", "app/ui/compose/with_rich_editor"], function(module, require, exports) { function tweetBoxManager() { this.createTweetBoxAtTarget = function(a, b) { this.createTweetBox(a.target, b) }, this.createTweetBox = function(a, b) { var c = $(a); if (!((b.eventData || {}).scribeContext || {}).component) throw new Error("Please specify scribing component for tweet box."); c.find(".geo-picker").length > 0 && GeoPicker.attachTo(c.find(".geo-picker"), b, { parent: c }); var d = c.find("div.rich-editor").length > 0 ? [withRichEditor] : [], e = (b.dmOnly ? DmComposer : TweetBox).mixin.apply(this, d), f = { typeaheadData: this.attr.typeaheadData }; e.attachTo(c, f, b) }, this.after("initialize", function() { this.on("uiInitTweetbox", this.createTweetBoxAtTarget) }) } var TweetBox = require("app/ui/compose/tweet_box"), DmComposer = require("app/ui/compose/dm_composer"), GeoPicker = require("app/ui/geo_picker"), defineComponent = require("core/component"), withRichEditor = require("app/ui/compose/with_rich_editor"), TweetBoxManager = defineComponent(tweetBoxManager); module.exports = TweetBoxManager });

    Obviously, this "answer" doesn't solve anything, but hopefully could provide enough to (re-)spark a conversation about this topic.

提交回复
热议问题