import Log from './Log' import { Emitter, isNum, isUndef, perfNow, isStr, extend, uniqId, isRegExp, isFn, $, Stack, isEmpty, contain, copy, each, toArr, keys, last, throttle, raf, xpath, isHidden, lowerCase, dateFormat } from '../lib/util' import evalCss from '../lib/evalCss' let id = 0 export default class Logger extends Emitter { constructor($container) { super() this._style = evalCss(require('./Logger.scss')) this._$container = $container this._container = $container.get(0) this._$el = $container.find('ul.eruda-logs') this._el = this._$el.get(0) this._$fakeEl = $container.find('ul.eruda-fake-logs') this._fakeEl = this._$fakeEl.get(0) this._$topSpace = $container.find('.eruda-top-space') this._topSpace = this._$topSpace.get(0) this._$bottomSpace = $container.find('.eruda-bottom-space') this._bottomSpace = this._$bottomSpace.get(0) this._topSpaceHeight = 0 this._bottomSpaceHeight = 0 this._logs = [] this._displayLogs = [] this._timer = {} this._count = {} this._lastLog = {} this._filter = 'all' this._maxNum = 'infinite' this._displayHeader = false this._asyncRender = false this._asyncList = [] this._asyncTimer = null this._isAtBottom = true this._groupStack = new Stack() this.renderViewport = throttle(() => { this._renderViewport() }, 16) // https://developers.google.cn/web/tools/chrome-devtools/console/utilities this._global = { copy(value) { if (!isStr(value)) value = JSON.stringify(value, null, 2) copy(value) }, $() { return document.querySelector.apply(document, arguments) }, $$() { return toArr(document.querySelectorAll.apply(document, arguments)) }, $x(path) { return xpath(path) }, clear: () => { this.clear() }, dir: value => { this.dir(value) }, table: (data, columns) => { this.table(data, columns) }, keys } this._bindEvent() } renderAsync(flag) { this._asyncRender = flag } setGlobal(name, val) { this._global[name] = val } displayHeader(flag) { this._displayHeader = flag } maxNum(val) { const logs = this._logs this._maxNum = val if (isNum(val) && logs.length > val) { this._logs = logs.slice(logs.length - val) this.render() } } displayUnenumerable(flag) { Log.showUnenumerable = flag } displayGetterVal(flag) { Log.showGetterVal = flag } lazyEvaluation(flag) { Log.lazyEvaluation = flag } viewLogInSources(flag) { Log.showSrcInSources = flag } destroy() { if (this._style) { evalCss.remove(this._style) } } filter(val) { this._filter = val this.emit('filter', val) return this.render() } count(label = 'default') { const count = this._count !isUndef(count[label]) ? count[label]++ : (count[label] = 1) return this.info(`${label}: ${count[label]}`) } countReset(label = 'default') { this._count[label] = 0 return this } assert(...args) { if (isEmpty(args)) return const exp = args.shift() if (!exp) { if (args.length === 0) args.unshift('console.assert') args.unshift('Assertion failed: ') return this.insert('error', args) } } log(...args) { if (isEmpty(args)) return return this.insert('log', args) } debug(...args) { if (isEmpty(args)) return return this.insert('debug', args) } dir(obj) { if (isUndef(obj)) return return this.insert('dir', [obj]) } table(...args) { if (isEmpty(args)) return return this.insert('table', args) } time(name = 'default') { if (this._timer[name]) { return this.insert('warn', [`Timer '${name}' already exists`]) } this._timer[name] = perfNow() return this } timeLog(name = 'default') { const startTime = this._timer[name] if (!startTime) { return this.insert('warn', [`Timer '${name}' does not exist`]) } return this.info(`${name}: ${perfNow() - startTime}ms`) } timeEnd(name = 'default') { this.timeLog(name) delete this._timer[name] return this } clear() { this.silentClear() return this.insert('log', [ '%cConsole was cleared', 'color:#808080;font-style:italic;' ]) } silentClear() { this._logs = [] this._displayLogs = [] this._lastLog = {} this._count = {} this._timer = {} this._groupStack = new Stack() this._asyncList = [] if (this._asyncTimer) { clearTimeout(this._asyncTimer) this._asyncTimer = null } return this.render() } info(...args) { if (isEmpty(args)) return return this.insert('info', args) } error(...args) { if (isEmpty(args)) return return this.insert('error', args) } warn(...args) { if (isEmpty(args)) return return this.insert('warn', args) } group(...args) { return this.insert({ type: 'group', args, ignoreFilter: true }) } groupCollapsed(...args) { return this.insert({ type: 'groupCollapsed', args, ignoreFilter: true }) } groupEnd() { this.insert('groupEnd') } input(jsCode) { this.insert({ type: 'input', args: [jsCode], ignoreFilter: true }) try { this.output(this._evalJs(jsCode)) } catch (e) { this.insert({ type: 'error', ignoreFilter: true, args: [e] }) } return this } output(val) { return this.insert({ type: 'output', args: [val], ignoreFilter: true }) } html(...args) { return this.insert('html', args) } render() { const logs = this._logs this._$el.html('') this._isAtBottom = true this._updateBottomSpace(0) this._updateTopSpace(0) this._displayLogs = [] for (let i = 0, len = logs.length; i < len; i++) { this._attachLog(logs[i]) } return this } insert(type, args) { let headers if (this._displayHeader) { headers = { time: getCurTime(), from: getFrom() } } this._asyncRender ? this.insertAsync(type, args, headers) : this.insertSync(type, args, headers) } insertAsync(type, args, headers) { this._asyncList.push([type, args, headers]) this._handleAsyncList() } insertSync(type, args, headers) { const logs = this._logs const groupStack = this._groupStack // Because asynchronous rendering, groupEnd must be put here. if (type === 'groupEnd') { const lastLog = this._lastLog lastLog.groupEnd() this._groupStack.pop() return this } const options = isStr(type) ? { type, args } : type if (groupStack.size > 0) { options.group = groupStack.peek() } extend(options, { id: ++id, headers }) if (options.type === 'group' || options.type === 'groupCollapsed') { const group = { id: uniqId('group'), collapsed: false, parent: groupStack.peek(), indentLevel: groupStack.size + 1 } if (options.type === 'groupCollapsed') group.collapsed = true options.targetGroup = group groupStack.push(group) } let log = new Log(options) log.on('updateSize', () => { this._isAtBottom = false this.renderViewport() }) const lastLog = this._lastLog if ( !contain(['html', 'group', 'groupCollapsed'], log.type) && lastLog.type === log.type && !log.src && !log.args && lastLog.text() === log.text() ) { lastLog.addCount() if (log.time) lastLog.updateTime(log.time) log = lastLog this._detachLog(lastLog) } else { logs.push(log) this._lastLog = log } if (this._maxNum !== 'infinite' && logs.length > this._maxNum) { const firstLog = logs[0] this._detachLog(firstLog) logs.shift() } this._attachLog(log) this.emit('insert', log) return this } toggleGroup(log) { const { targetGroup } = log targetGroup.collapsed ? this._openGroup(log) : this._collapseGroup(log) } _updateTopSpace(height) { this._topSpaceHeight = height this._topSpace.style.height = height + 'px' } _updateBottomSpace(height) { this._bottomSpaceHeight = height this._bottomSpace.style.height = height + 'px' } _updateLogSize(log) { const fakeEl = this._fakeEl if (isHidden(this._fakeEl)) return if (!log.isAttached()) { fakeEl.appendChild(log.el) log.updateSize() if (fakeEl.children > 100) { fakeEl.innerHTML = '' } return } log.updateSize() } _detachLog(log) { const displayLogs = this._displayLogs const idx = displayLogs.indexOf(log) if (idx > -1) { displayLogs.splice(idx, 1) this.renderViewport() } } // Binary search _attachLog(log) { if (!this._filterLog(log) || log.collapsed) return const displayLogs = this._displayLogs if (displayLogs.length === 0) { displayLogs.push(log) this.renderViewport() return } const lastDisplayLog = last(displayLogs) if (log.id > lastDisplayLog.id) { displayLogs.push(log) this.renderViewport() return } let startIdx = 0 let endIdx = displayLogs.length - 1 let middleLog let middleIdx while (startIdx <= endIdx) { middleIdx = startIdx + Math.floor((endIdx - startIdx) / 2) middleLog = displayLogs[middleIdx] if (middleLog.id === log.id) { return } if (middleLog.id < log.id) { startIdx = middleIdx + 1 } else { endIdx = middleIdx - 1 } } if (middleLog.id < log.id) { displayLogs.splice(middleIdx + 1, 0, log) } else { displayLogs.splice(middleIdx, 0, log) } this.renderViewport() } _handleAsyncList(timeout = 20) { const asyncList = this._asyncList if (this._asyncTimer) return this._asyncTimer = setTimeout(() => { this._asyncTimer = null let done = false const len = asyncList.length // insert faster if logs is huge, thus takes more time to render. let timeout, num if (len < 1000) { num = 200 timeout = 400 } else if (len < 5000) { num = 500 timeout = 800 } else if (len < 10000) { num = 800 timeout = 1000 } else if (len < 25000) { num = 1000 timeout = 1200 } else if (len < 50000) { num = 1500 timeout = 1500 } else { num = 2000 timeout = 2500 } if (num > len) { num = len done = true } for (let i = 0; i < num; i++) { const [type, args, headers] = asyncList.shift() this.insertSync(type, args, headers) } if (!done) raf(() => this._handleAsyncList(timeout)) }, timeout) } _injectGlobal() { each(this._global, (val, name) => { if (window[name]) return window[name] = val }) } _clearGlobal() { each(this._global, (val, name) => { if (window[name] && window[name] === val) { delete window[name] } }) } _evalJs(jsInput) { let ret this._injectGlobal() try { ret = eval.call(window, `(${jsInput})`) } catch (e) { ret = eval.call(window, jsInput) } this.setGlobal('$_', ret) this._clearGlobal() return ret } _filterLog(log) { const filter = this._filter if (filter === 'all') return true const isFilterRegExp = isRegExp(filter) const isFilterFn = isFn(filter) if (log.ignoreFilter) return true if (isFilterFn) return filter(log) if (isFilterRegExp) return filter.test(lowerCase(log.text())) return log.type === filter } _getLog(id) { const logs = this._logs let log for (let i = 0, len = logs.length; i < len; i++) { log = logs[i] if (log.id === id) break } return log } _collapseGroup(log) { const { targetGroup } = log targetGroup.collapsed = true log.updateIcon('caret-right') this._updateGroup(log) } _openGroup(log) { const { targetGroup } = log targetGroup.collapsed = false log.updateIcon('caret-down') this._updateGroup(log) } _updateGroup(log) { const { targetGroup } = log const logs = this._logs const len = logs.length let i = logs.indexOf(log) + 1 while (i < len) { const log = logs[i] if (!log.checkGroup() && log.group === targetGroup) { break } log.collapsed ? this._detachLog(log) : this._attachLog(log) i++ } } _bindEvent() { const self = this const $el = this._$el $el .on('click', '.eruda-log-container', function() { this.log.click(self) }) .on('click', '.eruda-icon-caret-down', function() { const $el = $(this) .parent() .parent() .parent() self._collapseGroup($el.get(0).log) }) .on('click', '.eruda-icon-caret-right', function() { const $el = $(this) .parent() .parent() .parent() self._openGroup($el.get(0).log) }) this._$container.on('scroll', () => { const { scrollHeight, offsetHeight, scrollTop } = this._container // safari bounce effect if (scrollTop < 0) return if (offsetHeight + scrollTop > scrollHeight) return let isAtBottom = false if (scrollHeight === offsetHeight) { isAtBottom = true } else if (scrollTop === scrollHeight - offsetHeight) { isAtBottom = true } this._isAtBottom = isAtBottom if ( this._topSpaceHeight < scrollTop && this._topSpaceHeight + this._el.offsetHeight > scrollTop + offsetHeight ) { return } this.renderViewport() }) } _renderViewport() { const container = this._container if (isHidden(container)) return const { scrollTop, clientWidth, offsetHeight } = container let top = scrollTop let bottom = scrollTop + offsetHeight const displayLogs = this._displayLogs const tolerance = 1000 top -= tolerance bottom += tolerance let topSpaceHeight = 0 let bottomSpaceHeight = 0 let currentHeight = 0 this._el.innerHTML = '' const len = displayLogs.length const fakeEl = this._fakeEl const fakeFrag = document.createDocumentFragment() const logs = [] for (let i = 0; i < len; i++) { const log = displayLogs[i] const { width, height } = log if (height === 0 || width !== clientWidth) { fakeFrag.appendChild(log.el) logs.push(log) } } if (logs.length > 0) { fakeEl.appendChild(fakeFrag) for (let i = 0, len = logs.length; i < len; i++) { logs[i].updateSize() } fakeEl.innerHTML = '' } const frag = document.createDocumentFragment() for (let i = 0; i < len; i++) { const log = displayLogs[i] const { el, height } = log if (currentHeight > bottom) { bottomSpaceHeight += height } else if (currentHeight + height > top) { frag.appendChild(el) } else if (currentHeight < top) { topSpaceHeight += height } currentHeight += height } this._el.appendChild(frag) this._updateTopSpace(topSpaceHeight) this._updateBottomSpace(bottomSpaceHeight) const { scrollHeight } = container if (this._isAtBottom && scrollTop !== scrollHeight - offsetHeight) { container.scrollTop = scrollHeight - offsetHeight this.renderViewport() } else { container.scrollTop = scrollTop } } } const getCurTime = () => dateFormat('HH:MM:ss ') function getFrom() { const e = new Error() let ret = '' const lines = e.stack ? e.stack.split('\n') : '' for (let i = 0, len = lines.length; i < len; i++) { ret = lines[i] if (ret.indexOf('winConsole') > -1 && i < len - 1) { ret = lines[i + 1] break } } return ret }