diff --git a/CHANGELOG.md b/CHANGELOG.md index c149da54..09ac340e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ English | [简体中文](./CHANGELOG_CN.md) +## 3.15.0 (2022-11-02) + +- `Feat(Log)` Add recycle scrolling to imporove performance, and add scroll to top/bottom buttons. (PR #570) +- `Feat(Log)` Add support for `console.group(), console.groupCollapsed(), console.groupEnd()`. (issue #545) +- `Feat(Network)` Add recycle scrolling to imporove performance. +- `Feat(Network)` Add "Start Time" of a request. +- `Feat(Network)` Use `curl` instead of `url` as the copy value of a request. (issue #410) +- `Fix(Storage)` Fix an event bug that overflow content cannot scroll. (issue #542) +- `Fix(Core)` Fix click event on `` 的点击事件问题。 (PR #577) + + ## 3.14.7 (2022-09-23) - `Perf(Log)` 优化打印日志时的性能。 (PR #567) diff --git a/dev/log.html b/dev/log.html index c5cbf0ea..9d2aa6cd 100644 --- a/dev/log.html +++ b/dev/log.html @@ -37,6 +37,7 @@ Log/Info/Debug/Warn/Error Output to Different Panels console.time + console.group
@@ -160,6 +161,25 @@ console.log('Wait...', i); } console.timeEnd(label); + console.timeEnd(label); +} + +function logGroup() { + vConsole.show(); + console.log('This is the outer level'); + console.group(); + console.log('Level 2'); + console.group(aa); + console.log('Level 3'); + console.groupCollapsed('LV4'); + console.warn('More of level 4'); + console.info(aa); + console.groupEnd(); + console.log('Back to level 3') + console.groupEnd(); + console.log('Back to level 2'); + console.groupEnd(); + console.log('Back to the outer level'); } function formattingLog() { diff --git a/dev/network.html b/dev/network.html index 7cb2c222..16ec4190 100644 --- a/dev/network.html +++ b/dev/network.html @@ -18,6 +18,7 @@
XMLHttpRequest
GET XHR + PUT XHR POST XHR (Data: Object) POST XHR (Data: JSON String) POST XHR (Massive Resp) @@ -34,6 +35,7 @@ GET Fetch Text GET Fetch JSON Text GET Fetch 500 + PUT Fetch POST Fetch POST Fetch Using XHR OPTIONS Fetch @@ -138,6 +140,30 @@ }; } +function putAjax() { + showPanel(); + const url = window.location.origin + '/dev/data/success.json?method=xhrGet&id=' + Math.random() + '&'; + + let postData = { + foo: 'bar', + book: { id: 123, type: 'comic', uid: [4,5,6,7,8] }, + id: `${Math.random()}${Math.random()}${Math.random()}${Math.random()}${Math.random()}${Math.random()}`, + type: 'xhr', + '': ' XSS Attack!' + }; + + const xhr = new XMLHttpRequest(); + xhr.open('PUT', url); + xhr.setRequestHeader('custom-header', 'foobar'); + xhr.send(postData); + xhr.onload = () => { + console.log('put XHR Response:', JSON.parse(xhr.response)); + }; + xhr.onerror = () => { + console.log('put XHR Error'); + }; +} + function getFetch() { showPanel(); window.fetch('./data/success.json?method=fetchGet&id=' + Math.random(), { @@ -185,6 +211,29 @@ }); } +function putFetch() { + showPanel(); + window.fetch('./data/success.json?method=fetchPut&id=' + Math.random(), { + method: 'PUT', + headers: { + 'custom-header': 'foobar', + 'content-type': 'application/json' + }, + body: { foo: 'bar', id: Math.random(), type: 'fetch' }, + }).then((data) => { + console.log('putFetch() response:', data); + setTimeout(() => { + data.json().then((res) => { + console.log(res); + }); + }, 3000); + // return data; + // return data.json(); + }).then((data) => { + // console.log('putFetch() json:', data); + }); +} + function postFetch() { window.fetch('./data/success.json?method=fetchPost&x=', { method: 'POST', diff --git a/doc/plugin_event_list.md b/doc/plugin_event_list.md index 292846d7..884e0e83 100644 --- a/doc/plugin_event_list.md +++ b/doc/plugin_event_list.md @@ -26,7 +26,13 @@ Trigger while vConsole trying to create a new tab for a plugin. This event will After binding this event, vConsole will get HTML from your callback to render a tab. A new tab will definitely be added if you bind this event, no matter what tab's HTML you set. Do not bind this event if you don't want to add a new tab. ##### Callback Arguments: -- (required) function(html): a callback function that receives the content HTML of the new tab. `html` can be a HTML `string` or an `HTMLElement` object (Or object which supports `appendTo()` method, like JQuery object). +- (required) function(html, options): a callback function that receives the content HTML of the new tab. `html` can be a HTML `string` or an `HTMLElement` object (Or object which supports `appendTo()` method, like JQuery object), and an optional `object` for tab options. + +A tab option is an object with properties: + +Property | | | | +------- | ------- | ------- | ------- +fixedHeight | boolean | optional | Whether the height of tab is fixed to 100%. ##### Example: diff --git a/doc/plugin_event_list_CN.md b/doc/plugin_event_list_CN.md index 9ba3b997..790ebfdb 100644 --- a/doc/plugin_event_list_CN.md +++ b/doc/plugin_event_list_CN.md @@ -33,7 +33,13 @@ myPlugin.on('init', function() { 绑定此事件后,vConsole 会认为此插件需要创建新 tab,并会将 callback 中获取的 HTML 用于渲染 tab。因此,只要绑定了此事件,新 tab 肯定会被渲染到页面中,无论 callback 传入的 HTML 是否为空。如果不需要添加新 tab,请不要绑定此事件。 ##### Callback 参数 -- (必填) function(html): 回调函数,接收一个 HTML 参数用于渲染 tab。`html` 可以为 HTML 字符串,或者 `HTMLElement` 对象(或支持 `appendTo()` 方法的对象,如 jQuery 对象)。 +- (必填) function(html, options): 回调函数,第一个参数接收一个 HTML 参数用于渲染 tab。`html` 可以为 HTML 字符串,或者 `HTMLElement` 对象(或支持 `appendTo()` 方法的对象,如 jQuery 对象)。第二个参数接收一个可选配置信息。 + +配置的参数为: + +Property | | | | +------- | ------- | ------- | ------- +fixedHeight | boolean | 选填 | tab 高度固定为 100%。 ##### 例子: diff --git a/package.json b/package.json index 32d86826..ecf0395b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vconsole", - "version": "3.14.7", + "version": "3.15.0", "description": "A lightweight, extendable front-end developer tool for mobile web page.", "homepage": "https://github.com/Tencent/vConsole", "files": [ diff --git a/src/component/icon.less b/src/component/icon/icon.less similarity index 100% rename from src/component/icon.less rename to src/component/icon/icon.less diff --git a/src/component/icon.svelte b/src/component/icon/icon.svelte similarity index 100% rename from src/component/icon.svelte rename to src/component/icon/icon.svelte diff --git a/src/component/iconCopy.svelte b/src/component/icon/iconCopy.svelte similarity index 90% rename from src/component/iconCopy.svelte rename to src/component/icon/iconCopy.svelte index 5ee1cb26..9afc1701 100644 --- a/src/component/iconCopy.svelte +++ b/src/component/icon/iconCopy.svelte @@ -1,7 +1,7 @@ + +
+ +
diff --git a/src/component/recycleScroller/recycleManager.ts b/src/component/recycleScroller/recycleManager.ts new file mode 100644 index 00000000..368c0faf --- /dev/null +++ b/src/component/recycleScroller/recycleManager.ts @@ -0,0 +1,161 @@ +const createRecycleManager = () => { + const recycles: { key: number; index: number; show: boolean }[] = []; + + const poolKeys: number[] = []; + let poolStartIndex = 0; + let poolEndIndex = 0; + + let lastItemCount = 0; + let lastStart = 0; + let lastEnd = 0; + + const update = (itemCount: number, start: number, end: number) => { + if (lastItemCount === itemCount && lastStart === start && lastEnd === end) + return recycles; + + const poolCount = poolKeys.length; + + // 计算新的 visible 区域 + const newFirstPool = + start <= poolEndIndex + ? // 1. 开头一定在 [0, start] + Math.max( + 0, + Math.min( + start, + // 2. 开头一定在 [poolStartIndex, poolEndIndex) 之间 + Math.max( + poolStartIndex, + Math.min(poolEndIndex - 1, end - poolCount) + ) + ) + ) + : start; // poolEndIndex 如果比 start 小,则前部无法保留下来 + + const newLastPool = + poolStartIndex <= end + ? // 1. 结尾一定在 [end, itemCount] 之间 + Math.max( + end, + Math.min( + itemCount, + // 2. 结尾一定在 (poolStartIndex, poolEndIndex] 之间 + Math.max( + poolStartIndex + 1, + Math.min(poolEndIndex, newFirstPool + poolCount) + ) + ) + ) + : end; // end 如果比 poolStartIndex 小,则后部无法保留下来 + + if (poolCount === 0 || newLastPool - newFirstPool < poolCount) { + // 无法复用,全都重新生成 + const count = (recycles.length = poolKeys.length = end - start); + for (let i = 0; i < count; i += 1) { + poolKeys[i] = i; + recycles[i] = { + key: i, + index: i + start, + show: true, + }; + } + poolStartIndex = start; + poolEndIndex = end; + lastItemCount = itemCount; + lastStart = start; + lastEnd = end; + return recycles; + } + + let usedPoolIndex = 0; + let usedPoolOffset = 0; + + // 复用区域 + let reuseStart = 0; + let reuseEnd = 0; + + if (poolEndIndex < newFirstPool || newLastPool < poolStartIndex) { + // 完全没有交集,随便复用 + reuseStart = newFirstPool; + reuseEnd = newFirstPool + poolCount; + } else if (poolStartIndex < newFirstPool) { + // 开头复用 + usedPoolOffset = newFirstPool - poolStartIndex; + reuseStart = newFirstPool; + reuseEnd = newFirstPool + poolCount; + } else if (newLastPool < poolEndIndex) { + // 尾部复用 + usedPoolOffset = poolCount - (poolEndIndex - newLastPool); + reuseStart = newLastPool - poolCount; + reuseEnd = newLastPool; + } else if (newFirstPool <= poolStartIndex && poolEndIndex <= newLastPool) { + // 新的 visible 是完全子集,直接复用 + reuseStart = poolStartIndex; + reuseEnd = poolEndIndex; + } + + // 开头不可见区域 + // 如果有不可见区域,则一定是来自上一次 visible 的复用 row + for (let i = newFirstPool; i < start; i += 1, usedPoolIndex += 1) { + const poolKey = poolKeys[(usedPoolOffset + usedPoolIndex) % poolCount]; + const recycle = recycles[i - newFirstPool]; + recycle.key = poolKey; + recycle.index = i; + recycle.show = false; + } + + // 可见区域 + for (let i = start, keyIndex = 0; i < end; i += 1) { + let poolKey: number; + if (reuseStart <= i && i < reuseEnd) { + // 复用 row + poolKey = poolKeys[(usedPoolOffset + usedPoolIndex) % poolCount]; + usedPoolIndex += 1; + } else { + // 新建 row + poolKey = poolCount + keyIndex; + keyIndex += 1; + } + const recycleIndex = i - newFirstPool; + if (recycleIndex < recycles.length) { + const recycle = recycles[recycleIndex]; + recycle.key = poolKey; + recycle.index = i; + recycle.show = true; + } else { + recycles.push({ + key: poolKey, + index: i, + show: true, + }); + } + } + + // 末尾不可见区域 + // 如果有不可见区域,则一定是来自上一次 visible 的复用 row + for (let i = end; i < newLastPool; i += 1, usedPoolIndex += 1) { + const poolKey = poolKeys[(usedPoolOffset + usedPoolIndex) % poolCount]; + const recycle = recycles[i - newFirstPool]; + recycle.key = poolKey; + recycle.index = i; + recycle.show = false; + } + + // 更新 poolKeys + for (let i = 0; i < recycles.length; i += 1) { + poolKeys[i] = recycles[i].key; + } + recycles.sort((a, b) => a.key - b.key); + poolStartIndex = newFirstPool; + poolEndIndex = newLastPool; + lastItemCount = itemCount; + lastStart = start; + lastEnd = end; + + return recycles; + }; + + return update; +}; + +export default createRecycleManager; diff --git a/src/component/recycleScroller/recycleScroller.less b/src/component/recycleScroller/recycleScroller.less new file mode 100644 index 00000000..2c9a73c4 --- /dev/null +++ b/src/component/recycleScroller/recycleScroller.less @@ -0,0 +1,43 @@ +.vc-scroller-viewport { + position: relative; + overflow: hidden; + height: 100%; +} + +.vc-scroller-contents { + min-height: 100%; + will-change: transform; +} + +.vc-scroller-items { + will-change: height; + position: relative; +} + +.vc-scroller-item { + display: none; + position: absolute; + left: 0; + right: 0; +} + +.vc-scroller-footer { + +} + +.vc-scroller-scrollbar-track { + width: 4px; + position: absolute; + top: 0; + right: 0; + bottom: 0; + padding: 1px; +} + +.vc-scroller-scrollbar-thumb { + position: relative; + width: 100%; + height: 100%; + background: rgba(0,0,0,.5); + border-radius: 999px; +} diff --git a/src/component/recycleScroller/recycleScroller.svelte b/src/component/recycleScroller/recycleScroller.svelte new file mode 100644 index 00000000..fe30208e --- /dev/null +++ b/src/component/recycleScroller/recycleScroller.svelte @@ -0,0 +1,384 @@ + + +
+
+ {#if $$slots.header} +
+ +
+ {/if} +
+ {#if items.length} + {#each visible as row, i (row.key)} + onRowResize(row.index, height)} + > + Missing template + + {/each} + {:else} + + {/if} +
+ {#if $$slots.footer} + + {/if} +
+ {#if scrollbar} +
+
+
+ {/if} +
diff --git a/src/component/recycleScroller/scroll/friction.ts b/src/component/recycleScroller/scroll/friction.ts new file mode 100644 index 00000000..6f3c15cf --- /dev/null +++ b/src/component/recycleScroller/scroll/friction.ts @@ -0,0 +1,43 @@ +/** * + * Friction physics simulation. Friction is actually just a simple + * power curve; the only trick is taking the natural log of the + * initial drag so that we can express the answer in terms of time. + */ +class Friction { + private _drag: number; + private _dragLog: number; + private _x = 0; + private _v = 0; + private _startTime = 0; + + constructor(drag: number) { + this._drag = drag; + this._dragLog = Math.log(drag); + } + + set(x: number, v: number, t?: number) { + this._x = x; + this._v = v; + this._startTime = t || Date.now(); + } + + x(t: number) { + const dt = (t - this._startTime) / 1000.0; + return ( + this._x + + (this._v * Math.pow(this._drag, dt)) / this._dragLog - + this._v / this._dragLog + ); + } + + dx(t: number) { + const dt = (t - this._startTime) / 1000.0; + return this._v * Math.pow(this._drag, dt); + } + + done(t: number) { + return Math.abs(this.dx(t)) < 3; + } +} + +export default Friction; diff --git a/src/component/recycleScroller/scroll/linear.ts b/src/component/recycleScroller/scroll/linear.ts new file mode 100644 index 00000000..4462a6c6 --- /dev/null +++ b/src/component/recycleScroller/scroll/linear.ts @@ -0,0 +1,32 @@ +class Linear { + private _x = 0; + private _endX = 0; + private _v = 0; + private _startTime = 0; + private _endTime = 0; + + set(x: number, endX: number, dt: number, t?: number) { + this._x = x; + this._endX = endX; + this._v = (endX - x) / dt; + this._startTime = t || Date.now(); + this._endTime = this._startTime + dt; + } + + x(t: number) { + if (this.done(t)) return this._endX; + const dt = t - this._startTime; + return this._x + this._v * dt; + } + + dx(t: number) { + if (this.done(t)) return 0; + return this._v; + } + + done(t: number) { + return t >= this._endTime; + } +} + +export default Linear; diff --git a/src/component/recycleScroller/scroll/scroll.ts b/src/component/recycleScroller/scroll/scroll.ts new file mode 100644 index 00000000..98b2dd76 --- /dev/null +++ b/src/component/recycleScroller/scroll/scroll.ts @@ -0,0 +1,86 @@ +import Friction from './friction'; +import Spring from './spring'; + +/** * + * Scroll combines Friction and Spring to provide the + * classic "flick-with-bounce" behavior. + */ +class Scroll { + private _getExtend: () => number; + private _friction = new Friction(0.05); + private _spring = new Spring(1, 90, 20); + private _toEdge = false; + + constructor(getExtend: () => number, private _enableSpring: boolean) { + this._getExtend = getExtend; + } + + set(x: number, v: number, t?: number) { + if (t === undefined) t = Date.now(); + this._friction.set(x, v, t); + + // If we're over the extent or zero then start springing. Notice that we also consult + // velocity because we don't want flicks that start in the overscroll to get consumed + // by the spring. + if (x > 0 && v >= 0) { + this._toEdge = true; + if (this._enableSpring) { + this._spring.set(0, x, v, t); + } + } else { + const extent = this._getExtend(); + if (x < -extent && v <= 0) { + this._toEdge = true; + if (this._enableSpring) { + this._spring.set(-extent, x, v, t); + } + } else { + this._toEdge = false; + } + } + } + + x(t: number) { + // We've entered the spring, use the value from there. + if (this._enableSpring && this._toEdge) { + return this._spring.x(t); + } + // We're still in friction. + const x = this._friction.x(t); + const dx = this._friction.dx(t); + // If we've gone over the edge the roll the momentum into the spring. + if (x > 0 && dx >= 0) { + this._toEdge = true; + if (this._enableSpring) { + this._spring.set(0, x, dx, t); + } else { + return 0; + } + } else { + const extent = this._getExtend(); + if (x < -extent && dx <= 0) { + this._toEdge = true; + if (this._enableSpring) { + this._spring.set(-extent, x, dx, t); + } else { + return -extent; + } + } + } + return x; + } + + dx(t: number) { + if (!this._toEdge) return this._friction.dx(t); + if (this._enableSpring) return this._spring.dx(t); + return 0; + } + + done(t: number) { + if (!this._toEdge) return this._friction.done(t); + if (this._enableSpring) return this._spring.done(t); + return true; + } +} + +export default Scroll; diff --git a/src/component/recycleScroller/scroll/scrollHandler.ts b/src/component/recycleScroller/scroll/scrollHandler.ts new file mode 100644 index 00000000..5fb5b7ff --- /dev/null +++ b/src/component/recycleScroller/scroll/scrollHandler.ts @@ -0,0 +1,194 @@ +import Linear from './linear'; +import Scroll from './scroll'; +import { TrackerHandler } from './touchTracker'; + +// This function sets up a requestAnimationFrame-based timer which calls +// the callback every frame while the physics model is still moving. +// It returns a function that may be called to cancel the animation. +function animation( + physicsModel: { done: (t: number) => boolean }, + callback: (t: number) => void +) { + let id: ReturnType; + let cancelled: boolean; + + const onFrame = () => { + if (cancelled) return; + const t = Date.now(); + callback(t); + if (physicsModel.done(t)) return; + id = requestAnimationFrame(onFrame); + }; + + const cancel = () => { + cancelAnimationFrame(id); + cancelled = true; + }; + + onFrame(); + + return { cancel }; +} + +const UNDERSCROLL_TRACKING = 0; + +class ScrollHandler implements TrackerHandler { + private _scrollModel: Scroll; + private _linearModel: Linear; + private _startPosition = 0; + private _position = 0; + private _animate: ReturnType | null = null; + private _getExtent: () => number; + + constructor( + getExtent: () => number, + private _updatePosition: (pos: number) => void + ) { + this._getExtent = getExtent; + this._scrollModel = new Scroll(getExtent, false); + this._linearModel = new Linear(); + } + + onTouchStart() { + let pos = this._position; + + if (pos > 0) { + pos *= UNDERSCROLL_TRACKING; + } else { + const extent = this._getExtent(); + if (pos < -extent) { + pos = (pos + extent) * UNDERSCROLL_TRACKING - extent; + } + } + + this._startPosition = this._position = pos; + + if (this._animate) { + this._animate.cancel(); + this._animate = null; + } + + this._updatePosition(-pos); + } + + onTouchMove(dx: number, dy: number) { + let pos = dy + this._startPosition; + + if (pos > 0) { + pos *= UNDERSCROLL_TRACKING; + } else { + const extent = this._getExtent(); + if (pos < -extent) { + pos = (pos + extent) * UNDERSCROLL_TRACKING - extent; + } + } + + this._position = pos; + + this._updatePosition(-pos); + } + + onTouchEnd(dx: number, dy: number, velocityX: number, velocityY: number) { + let pos = dy + this._startPosition; + + if (pos > 0) { + pos *= UNDERSCROLL_TRACKING; + } else { + const extent = this._getExtent(); + if (pos < -extent) { + pos = (pos + extent) * UNDERSCROLL_TRACKING - extent; + } + } + this._position = pos; + this._updatePosition(-pos); + if (Math.abs(dy) <= 0.1 && Math.abs(velocityY) <= 0.1) return; + const scroll = this._scrollModel; + scroll.set(pos, velocityY); + this._animate = animation(scroll, (t) => { + const pos = (this._position = scroll.x(t)); + this._updatePosition(-pos); + }); + } + + onTouchCancel(): void { + let pos = this._position; + + if (pos > 0) { + pos *= UNDERSCROLL_TRACKING; + } else { + const extent = this._getExtent(); + if (pos < -extent) { + pos = (pos + extent) * UNDERSCROLL_TRACKING - extent; + } + } + + this._position = pos; + const scroll = this._scrollModel; + scroll.set(pos, 0); + this._animate = animation(scroll, (t) => { + const pos = (this._position = scroll.x(t)); + this._updatePosition(-pos); + }); + } + + onWheel(x: number, y: number): void { + let pos = this._position - y; + + if (this._animate) { + this._animate.cancel(); + this._animate = null; + } + + if (pos > 0) { + pos = 0; + } else { + const extent = this._getExtent(); + if (pos < -extent) { + pos = -extent; + } + } + + this._position = pos; + + this._updatePosition(-pos); + } + + getPosition() { + return -this._position; + } + + updatePosition(position: number) { + const dx = -position - this._position; + this._startPosition += dx; + this._position += dx; + const pos = this._position; + + this._updatePosition(-pos); + + const scroll = this._scrollModel; + const t = Date.now(); + if (!scroll.done(t)) { + const dx = scroll.dx(t); + scroll.set(pos, dx, t); + } + } + + scrollTo(position: number, duration?: number) { + if (this._animate) { + this._animate.cancel(); + this._animate = null + } + if (duration > 0) { + const linearModel = this._linearModel; + linearModel.set(this._position, -position, duration); + this._animate = animation(this._linearModel, (t) => { + const pos = (this._position = linearModel.x(t)); + this._updatePosition(-pos); + }); + } else { + this._updatePosition(position); + } + } +} + +export default ScrollHandler; diff --git a/src/component/recycleScroller/scroll/spring.ts b/src/component/recycleScroller/scroll/spring.ts new file mode 100644 index 00000000..603a0f13 --- /dev/null +++ b/src/component/recycleScroller/scroll/spring.ts @@ -0,0 +1,132 @@ +const epsilon = 0.1; +const almostEqual = (a: number, b: number) => + a > b - epsilon && a < b + epsilon; +const almostZero = (a: number) => almostEqual(a, 0); + +/*** + * Simple Spring implementation -- this implements a damped spring using a symbolic integration + * of Hooke's law: F = -kx - cv. This solution is significantly more performant and less code than + * a numerical approach such as Facebook Rebound which uses RK4. + * + * This physics textbook explains the model: + * http://www.stewartcalculus.com/data/CALCULUS%20Concepts%20and%20Contexts/upfiles/3c3-AppsOf2ndOrders_Stu.pdf + * + * A critically damped spring has: damping*damping - 4 * mass * springConstant == 0. If it's greater than zero + * then the spring is overdamped, if it's less than zero then it's underdamped. + */ +const getSolver = ( + mass: number, + springConstant: number, + damping: number +): (( + initial: number, + velocity: number +) => { x: (t: number) => number; dx: (t: number) => number }) => { + const c = damping; + const m = mass; + const k = springConstant; + const cmk = c * c - 4 * m * k; + if (cmk == 0) { + // The spring is critically damped. + // x = (c1 + c2*t) * e ^(-c/2m)*t + const r = -c / (2 * m); + return (initial, velocity) => { + const c1 = initial; + const c2 = velocity / (r * initial); + return { + x: (dt) => (c1 + c2 * dt) * Math.pow(Math.E, r * dt), + dx: (dt) => (r * (c1 + c2 * dt) + c2) * Math.pow(Math.E, r * dt), + }; + }; + } else if (cmk > 0) { + // The spring is overdamped; no bounces. + // x = c1*e^(r1*t) + c2*e^(r2t) + // Need to find r1 and r2, the roots, then solve c1 and c2. + const r1 = (-c - Math.sqrt(cmk)) / (2 * m); + const r2 = (-c + Math.sqrt(cmk)) / (2 * m); + return (initial, velocity) => { + const c2 = (velocity - r1 * initial) / (r2 - r1); + const c1 = initial - c2; + + return { + x: (dt) => c1 * Math.pow(Math.E, r1 * dt) + c2 * Math.pow(Math.E, r2 * dt), + dx: (dt) => + c1 * r1 * Math.pow(Math.E, r1 * dt) + + c2 * r2 * Math.pow(Math.E, r2 * dt), + }; + }; + } else { + // The spring is underdamped, it has imaginary roots. + // r = -(c / 2*m) +- w*i + // w = sqrt(4mk - c^2) / 2m + // x = (e^-(c/2m)t) * (c1 * cos(wt) + c2 * sin(wt)) + const w = Math.sqrt(4 * m * k - c * c) / (2 * m); + const r = -((c / 2) * m); + return (initial, velocity) => { + const c1 = initial; + const c2 = (velocity - r * initial) / w; + + return { + x: (dt) => + Math.pow(Math.E, r * dt) * + (c1 * Math.cos(w * dt) + c2 * Math.sin(w * dt)), + dx: (dt) => { + const power = Math.pow(Math.E, r * dt); + const cos = Math.cos(w * dt); + const sin = Math.sin(w * dt); + return ( + power * (c2 * w * cos - c1 * w * sin) + + r * power * (c2 * sin + c1 * cos) + ); + }, + }; + }; + } +}; + +class Spring { + + private _solver: ( + initial: number, + velocity: number + ) => { + x: (dt: number) => number; + dx: (dt: number) => number; + }; + private _solution: { + x: (dt: number) => number; + dx: (dt: number) => number; + } | null; + private _endPosition: number; + private _startTime: number; + + constructor(mass: number, springConstant: number, damping: number) { + this._solver = getSolver(mass, springConstant, damping); + this._solution = null; + this._endPosition = 0; + this._startTime = 0; + } + x(t: number) { + if (!this._solution) return 0; + const dt = (t - this._startTime) / 1000.0; + return this._endPosition + this._solution.x(dt); + } + dx(t: number) { + if (!this._solution) return 0; + const dt = (t - this._startTime) / 1000.0; + return this._solution.dx(dt); + } + set(endPosition: number, x: number, velocity: number, t?: number) { + if (!t) t = Date.now(); + this._endPosition = endPosition; + if (x == endPosition && almostZero(velocity)) return; + this._solution = this._solver(x - endPosition, velocity); + this._startTime = t; + } + done(t: number) { + if (!t) t = Date.now(); + return almostEqual(this.x(t), this._endPosition) && almostZero(this.dx(t)); + } +} + +export default Spring; diff --git a/src/component/recycleScroller/scroll/touchTracker.ts b/src/component/recycleScroller/scroll/touchTracker.ts new file mode 100644 index 00000000..3e2cef37 --- /dev/null +++ b/src/component/recycleScroller/scroll/touchTracker.ts @@ -0,0 +1,168 @@ +export interface TrackerHandler { + onTouchStart(): void; + onTouchMove(x: number, y: number): void; + onTouchEnd(x: number, y: number, velocityX: number, velocityY: number): void; + onTouchCancel(): void; + onWheel(x: number, y: number): void; +} + +const throttleRAF = (fn: () => void) => { + let timer: ReturnType | null = null; + let call = false; + + const notify = () => { + call = false; + fn(); + timer = requestAnimationFrame(() => { + timer = null + if (call) notify() + }); + } + + const trigger = () => { + if (timer === null) { + notify(); + } else { + call = true; + } + } + + const cancel = () => { + if (timer) { + cancelAnimationFrame(timer); + call = false; + timer = null; + } + } + + return { + trigger, + cancel, + } +} + +class TouchTracker { + private _touchId: number | null = null; + private _startX = 0; + private _startY = 0; + private _historyX: number[] = []; + private _historyY: number[] = []; + private _historyTime: number[] = []; + private _wheelDeltaX = 0; + private _wheelDeltaY = 0; + + constructor(private _handler: TrackerHandler) {} + + private _getTouchDelta(e: TouchEvent): { x: number; y: number } | null { + if (this._touchId === null) return null; + for (const touch of e.changedTouches) { + if (touch.identifier === this._touchId) { + return { + x: touch.pageX - this._startX, + y: touch.pageY - this._startY, + }; + } + } + return null; + } + + private _onTouchMove = () => { + const deltaX = this._historyX[this._historyX.length - 1]; + const deltaY = this._historyY[this._historyY.length - 1]; + this._handler.onTouchMove(deltaX, deltaY); + } + + private _onWheel = throttleRAF(() => { + const deltaX = this._wheelDeltaX; + const deltaY = this._wheelDeltaY; + + this._wheelDeltaX = 0; + this._wheelDeltaY = 0; + + this._handler.onWheel(deltaX, deltaY); + }) + + handleTouchStart = (e: TouchEvent) => { + if ((e.target).dataset?.scrollable === '1') { return; } + e.preventDefault(); + + const touch = e.touches[0]; + this._touchId = touch.identifier; + this._startX = touch.pageX; + this._startY = touch.pageY; + this._historyX = [0]; + this._historyY = [0]; + this._historyTime = [Date.now()]; + + this._handler.onTouchStart(); + }; + + handleTouchMove = (e: TouchEvent) => { + if ((e.target).dataset?.scrollable === '1') { return; } + e.preventDefault(); + + const delta = this._getTouchDelta(e); + if (delta === null) return; + + this._historyX.push(delta.x); + this._historyY.push(delta.y); + this._historyTime.push(Date.now()); + + this._onTouchMove(); + }; + + handleTouchEnd = (e: TouchEvent) => { + if ((e.target).dataset?.scrollable === '1') { return; } + e.preventDefault(); + + const delta = this._getTouchDelta(e); + if (delta === null) return; + + let velocityX = 0; + let velocityY = 0; + const lastTime = Date.now(); + const lastY = delta.y; + const lastX = delta.x; + const historyTime = this._historyTime; + for (let i = historyTime.length - 1; i > 0; i -= 1) { + const t = historyTime[i]; + const dt = lastTime - t; + if (dt > 30) { + velocityX = ((lastX - this._historyX[i]) * 1000) / dt; + velocityY = ((lastY - this._historyY[i]) * 1000) / dt; + break; + } + } + + this._touchId = null; + + // ;(window as any)._vcOrigConsole.log('onTouchEnd', delta, velocityX, velocityY); + this._handler.onTouchEnd(delta.x, delta.y, velocityX, velocityY); + }; + + handleTouchCancel = (e: TouchEvent) => { + if ((e.target).dataset?.scrollable === '1') { return; } + e.preventDefault(); + + const delta = this._getTouchDelta(e); + if (delta === null) return; + + this._touchId = null; + + // ;(window as any)._vcOrigConsole.log('onTouchCancel'); + this._handler.onTouchCancel(); + }; + + handleWheel = (e: WheelEvent) => { + if ((e.target).dataset?.scrollable === '1') { return; } + e.preventDefault(); + + this._wheelDeltaX += e.deltaX; + this._wheelDeltaY += e.deltaY; + + this._onWheel.trigger(); + // ;(window as any)._vcOrigConsole.log('onWheel', e.target); + }; +} + +export default TouchTracker; diff --git a/src/core/core.svelte b/src/core/core.svelte index e894daf8..3729b160 100644 --- a/src/core/core.svelte +++ b/src/core/core.svelte @@ -4,7 +4,7 @@ import { default as SwitchButton } from './switchButton.svelte'; import { contentStore } from './core.model'; import Style from './core.less'; - import type { IVConsoleTopbarOptions, IVConsoleToolbarOptions } from '../lib/plugin'; + import type { IVConsoleTopbarOptions, IVConsoleToolbarOptions, IVConsoleTabOptions } from '../lib/plugin'; /************************************* * Public properties @@ -14,6 +14,7 @@ id: string; name: string; hasTabPanel: boolean; + tabOptions?: IVConsoleTabOptions; topbarList?: IVConsoleTopbarOptions[]; toolbarList?: IVConsoleToolbarOptions[]; } @@ -165,6 +166,23 @@ } }; const onContentTouchStart = (e) => { + // skip inputs + let isInputElement = e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA'; + if (isInputElement) { + return; + } + // skip scrollable elements + let isScrollElement = false; + if (typeof window.getComputedStyle === 'function') { + const style = window.getComputedStyle(e.target); + if (style.overflow === 'auto' || style.overflow === 'scroll') { + isScrollElement = true; + } + } + if (isScrollElement) { + // (window as any)._vcOrigConsole.log('onContentTouchStart isScrollElement', isScrollElement); + return; + } const top = divContent.scrollTop, totalScroll = divContent.scrollHeight, currentScroll = top + divContent.offsetHeight; @@ -176,26 +194,23 @@ // scrollTop always equals to 0 (it is always on the top), // so we need to prevent scroll event manually if (divContent.scrollTop === 0) { - if (e.target.classList && !e.target.classList.contains('vc-cmd-input')) { // skip input - preventContentMove = true; - } + preventContentMove = true; } } else if (currentScroll === totalScroll) { // when content is on the bottom, // do similar processing divContent.scrollTop = top - 1; if (divContent.scrollTop === top) { - if (e.target.classList && !e.target.classList.contains('vc-cmd-input')) { - preventContentMove = true; - } + preventContentMove = true; } } - // (window as any)._vcOrigConsole.log('preventContentMove', preventContentMove); + // (window as any)._vcOrigConsole.log('onContentTouchStart preventContentMove', preventContentMove); }; const onContentTouchMove = (e) => { if (preventContentMove) { e.preventDefault(); } + // (window as any)._vcOrigConsole.log('onContentTouchMove preventContentMove', preventContentMove); }; const onContentTouchEnd = (e) => { preventContentMove = false; @@ -235,18 +250,29 @@ }, touchMove(e) { const touch = e.changedTouches[0]; - if (Math.abs(touch.pageX - mockTapInfo.touchstartX) > mockTapInfo.tapBoundary || Math.abs(touch.pageY - mockTapInfo.touchstartY) > mockTapInfo.tapBoundary) { + if ( + Math.abs(touch.pageX - mockTapInfo.touchstartX) > mockTapInfo.tapBoundary || + Math.abs(touch.pageY - mockTapInfo.touchstartY) > mockTapInfo.tapBoundary + ) { mockTapInfo.touchHasMoved = true; } + // (window as any)._vcOrigConsole.log('mockTapEvent.touchMove', mockTapInfo.touchHasMoved); }, touchEnd(e) { // move and time within limits, manually trigger `click` event - if (mockTapInfo.touchHasMoved === false && e.timeStamp - mockTapInfo.lastTouchStartTime < mockTapInfo.tapTime && mockTapInfo.targetElem != null) { + if ( + mockTapInfo.touchHasMoved === false && + e.timeStamp - mockTapInfo.lastTouchStartTime < mockTapInfo.tapTime && + mockTapInfo.targetElem != null + ) { const tagName = mockTapInfo.targetElem.tagName.toLowerCase(); let needFocus = false; switch (tagName) { case 'textarea': // focus needFocus = true; break; + case 'select': + needFocus = !mockTapInfo.targetElem.disabled && !mockTapInfo.targetElem.readOnly; + break; case 'input': switch (mockTapInfo.targetElem.type) { case 'button': @@ -345,6 +371,7 @@
{/each} diff --git a/src/core/core.ts b/src/core/core.ts index 8d7aa02b..843db2bf 100644 --- a/src/core/core.ts +++ b/src/core/core.ts @@ -290,6 +290,7 @@ export class VConsole { id: plugin.id, name: plugin.name, hasTabPanel: false, + tabOptions: undefined, topbarList: [], toolbarList: [], }; @@ -297,14 +298,16 @@ export class VConsole { // start init plugin.trigger('init'); // render tab (if it is a tab plugin then it should has tab-related events) - plugin.trigger('renderTab', (tabboxHTML) => { + plugin.trigger('renderTab', (tabboxHTML, options = {}) => { // render tabbar - this.compInstance.pluginList[plugin.id].hasTabPanel = true; + const pluginInfo = this.compInstance.pluginList[plugin.id] + pluginInfo.hasTabPanel = true; + pluginInfo.tabOptions = options; // render tabbox if (!!tabboxHTML) { // when built-in plugins are initializing in the same time, // plugin's `.vc-plugin-box` element will be re-order by `pluginOrder` option, - // so the innerHTML should be inserted with a delay + // so the innerHTML should be inserted with a delay // to make sure getting the right `.vc-plugin-box`. (issue #559) setTimeout(() => { const divContentInner = document.querySelector('#__vc_plug_' + plugin.id); diff --git a/src/core/style/theme.less b/src/core/style/theme.less index f5d81a44..5b1b734a 100644 --- a/src/core/style/theme.less +++ b/src/core/style/theme.less @@ -78,6 +78,7 @@ // table .vc-table { + height: 100%; .vc-table-row { line-height: 1.5; diff --git a/src/core/style/view.less b/src/core/style/view.less index d7247326..539cba61 100644 --- a/src/core/style/view.less +++ b/src/core/style/view.less @@ -76,14 +76,20 @@ position: relative; min-height: 100%; } +.vc-plugin-box.vc-fixed-height { + height: 100%; +} .vc-plugin-box.vc-actived { display: block; } .vc-plugin-content { - padding-bottom: (39em / @font) * 2; + display: flex; + width: 100%; + height: 100%; + overflow-y: auto; + flex-direction: column; -webkit-tap-highlight-color: transparent; } -.vc-plugin-empty:before, .vc-plugin-content:empty:before { content: "Empty"; color: var(--VC-FG-1); @@ -96,6 +102,17 @@ text-align: center; } +.vc-plugin-empty { + color: var(--VC-FG-1); + font-size: (15em / @font); + height: 100%; + width: 100%; + padding: (15em / @font) 0; + text-align: center; + display: flex; + flex-direction: column; + justify-content: center +} // safe area diff --git a/src/lib/plugin.ts b/src/lib/plugin.ts index 5a9a7de0..ebe0ca93 100644 --- a/src/lib/plugin.ts +++ b/src/lib/plugin.ts @@ -30,6 +30,10 @@ export interface IVConsoleToolbarOptions { onClick?: (e: Event, data?: any) => any; } +export interface IVConsoleTabOptions { + fixedHeight?: boolean +} + /** * vConsole Plugin Base Class */ @@ -40,7 +44,7 @@ export class VConsolePlugin { protected _id: string; protected _name: string; protected _vConsole: VConsole; - + constructor(...args); constructor(id: string, name = 'newPlugin') { this.id = id; diff --git a/src/lib/sveltePlugin.ts b/src/lib/sveltePlugin.ts index 7b837ebf..5d09da35 100644 --- a/src/lib/sveltePlugin.ts +++ b/src/lib/sveltePlugin.ts @@ -23,12 +23,12 @@ export class VConsoleSveltePlugin extends VConsolePlugin { onRenderTab(callback) { const $container = document.createElement('div'); - this.compInstance = new this.CompClass({ + const compInstance = this.compInstance = new this.CompClass({ target: $container, props: this.initialProps, }); // console.log('onRenderTab', this.compInstance); - callback($container.firstElementChild); + callback($container.firstElementChild, compInstance.options); } onRemove() { diff --git a/src/log/log.less b/src/log/log.less index bfd11e6e..38905992 100644 --- a/src/log/log.less +++ b/src/log/log.less @@ -1,5 +1,5 @@ @import "../styles/var.less"; .vc-logs-has-cmd { - padding-bottom: (80em / @font); + // padding-bottom: (80em / @font); } diff --git a/src/log/log.model.ts b/src/log/log.model.ts index 69762366..ea8b2cd3 100644 --- a/src/log/log.model.ts +++ b/src/log/log.model.ts @@ -20,9 +20,15 @@ export interface IVConsoleLog { _id: string; type: IConsoleLogMethod; cmdType?: 'input' | 'output'; - repeated?: number; + repeated: number; + toggle: Record; date: number; data: IVConsoleLogData[]; // the `args: any[]` of `console.log(...args)` + // hide?: boolean; + groupLevel: number; + groupLabel?: symbol; + groupHeader?: 0 | 1 | 2; // 0=not_header, 1=is_header(no_collapsed), 2=is_header(collapsed) + groupCollapsed?: boolean; // collapsed by it's group header } export type IVConsoleLogListMap = { [pluginId: string]: IVConsoleLog[] }; @@ -55,6 +61,8 @@ export class VConsoleLogModel extends VConsoleModel { public ADDED_LOG_PLUGIN_ID: string[] = []; public maxLogNumber: number = 1000; protected logCounter: number = 0; // a counter used to do some tasks on a regular basis + protected groupLevel: number = 0; // for `console.group()` + protected groupLabelCollapsedStack: { label: symbol, collapsed: boolean }[] = []; protected pluginPattern: RegExp; protected logQueue: IVConsoleLog[] = []; protected flushLogScheduled: boolean = false; @@ -121,21 +129,33 @@ export class VConsoleLogModel extends VConsoleModel { if (typeof this.origConsole.log === 'function') { return; } - const methodList = this.LOG_METHODS; - + // save original console object if (!window.console) { (window.console) = {}; } else { - methodList.map((method) => { + this.LOG_METHODS.map((method) => { this.origConsole[method] = window.console[method]; }); this.origConsole.time = window.console.time; this.origConsole.timeEnd = window.console.timeEnd; this.origConsole.clear = window.console.clear; + this.origConsole.group = window.console.group; + this.origConsole.groupCollapsed = window.console.groupCollapsed; + this.origConsole.groupEnd = window.console.groupEnd; } - methodList.map((method) => { + this._mockConsoleLog(); + this._mockConsoleTime(); + this._mockConsoleGroup(); + this._mockConsoleClear(); + + // convenient for other uses + (window)._vcOrigConsole = this.origConsole; + } + + protected _mockConsoleLog() { + this.LOG_METHODS.map((method) => { window.console[method] = ((...args) => { this.addLog({ type: method, @@ -143,34 +163,67 @@ export class VConsoleLogModel extends VConsoleModel { }); }).bind(window.console); }); + } + protected _mockConsoleTime() { const timeLog: { [label: string]: number } = {}; + window.console.time = ((label: string = '') => { timeLog[label] = Date.now(); }).bind(window.console); + window.console.timeEnd = ((label: string = '') => { const pre = timeLog[label]; + let t = 0; if (pre) { - this.addLog({ - type: 'log', - origData: [label + ':', (Date.now() - pre) + 'ms'], - }); + t = Date.now() - pre; delete timeLog[label]; - } else { + } + this.addLog({ + type: 'log', + origData: [`${label}: ${t}ms`], + }); + }).bind(window.console); + } + + protected _mockConsoleGroup() { + const groupFunction = (isCollapsed: boolean) => { + return ((label = 'console.group') => { + const labelSymbol = Symbol(label); + this.groupLabelCollapsedStack.push({ label: labelSymbol, collapsed: isCollapsed }); + this.addLog({ type: 'log', - origData: [label + ': 0ms'], + origData: [label], + isGroupHeader: isCollapsed ? 2 : 1, + isGroupCollapsed: false, + }, { + noOrig: true, }); - } + + this.groupLevel++; + if (isCollapsed) { + this.origConsole.groupCollapsed(label); + } else { + this.origConsole.group(label); + } + }).bind(window.console); + }; + window.console.group = groupFunction(false); + window.console.groupCollapsed = groupFunction(true); + + window.console.groupEnd = (() => { + this.groupLabelCollapsedStack.pop(); + this.groupLevel = Math.max(0, this.groupLevel - 1); + this.origConsole.groupEnd(); }).bind(window.console); + } + protected _mockConsoleClear() { window.console.clear = ((...args) => { this.clearLog(); this.callOriginalConsole('clear', ...args); }).bind(window.console); - - // convenient for other uses - (window)._vcOrigConsole = this.origConsole; } /** @@ -234,22 +287,32 @@ export class VConsoleLogModel extends VConsoleModel { /** * Add a vConsole log. */ - public addLog(item: { type: IConsoleLogMethod, origData: any[] } = { type: 'log', origData: [] }, opt?: IVConsoleAddLogOptions) { + public addLog( + item: { + type: IConsoleLogMethod, + origData: any[], + isGroupHeader?: 0 | 1 | 2, + isGroupCollapsed?: boolean, + } = { type: 'log', origData: [], isGroupHeader: 0, isGroupCollapsed: false, }, + opt?: IVConsoleAddLogOptions + ) { + // get group + const previousGroup = this.groupLabelCollapsedStack[this.groupLabelCollapsedStack.length - 2]; + const currentGroup = this.groupLabelCollapsedStack[this.groupLabelCollapsedStack.length - 1]; // prepare data const log: IVConsoleLog = { _id: tool.getUniqueID(), type: item.type, cmdType: opt?.cmdType, + toggle: {}, date: Date.now(), data: getLogDatasWithFormatting(item.origData || []), + repeated: 0, + groupLabel: currentGroup?.label, + groupLevel: this.groupLevel, + groupHeader: item.isGroupHeader, + groupCollapsed: item.isGroupHeader ? !!previousGroup?.collapsed : !!currentGroup?.collapsed, }; - // for (let i = 0; i < item?.origData.length; i++) { - // const data: IVConsoleLogData = { - // origData: item.origData[i], - // }; - // log.data.push(data); - // } - // log.data = getLogDatasWithFormatting(item?.origData); this._signalLog(log); diff --git a/src/log/log.svelte b/src/log/log.svelte index 8ac0c4f5..d7d5734f 100644 --- a/src/log/log.svelte +++ b/src/log/log.svelte @@ -1,27 +1,43 @@
- {#if $store && $store.logList.length > 0} - {#each $store.logList as log (log._id)} - {#if ( - // filterType - filterType === 'all' || filterType === log.type) - && - // filterText - (filterText === '' || isMatchedFilterText(log, filterText) - )} - + +
Empty
+ + + {#if showCmd} + {/if} - {/each} - {:else} -
- {/if} - - {#if showCmd} - - {/if} - +
+
diff --git a/src/log/log.ts b/src/log/log.ts index a59714b5..73c49e46 100644 --- a/src/log/log.ts +++ b/src/log/log.ts @@ -61,6 +61,18 @@ export class VConsoleLogPlugin extends VConsoleSveltePlugin { this.model.clearPluginLog(this.id); this.vConsole.triggerEvent('clearLog'); } + }, { + name: 'Top', + global: false, + onClick: (e) => { + this.compInstance.scrollToTop() + } + }, { + name: 'Bottom', + global: false, + onClick: (e) => { + this.compInstance.scrollToBottom() + } }]; callback(toolList); } diff --git a/src/log/logCommand.less b/src/log/logCommand.less index 637dd795..7e5eff79 100644 --- a/src/log/logCommand.less +++ b/src/log/logCommand.less @@ -2,13 +2,10 @@ // container .vc-cmd { - position: absolute; height: (40em / @font); - left: 0; - right: 0; - bottom: (40em / @font); border-top: 1px solid var(--VC-FG-3); - display: block !important; + display: flex; + flex-direction: row; &.vc-filter{ bottom: 0; @@ -19,9 +16,10 @@ .vc-cmd-input-wrap { display: flex; align-items: center; + flex: 1; position: relative; height: (28em / @font); - margin-right: (40em / @font); + // margin-right: (40em / @font); padding: (6em / @font) (8em / @font); } .vc-cmd-input { @@ -40,10 +38,10 @@ // button .vc-cmd-btn { - position: absolute; - top: 0; - right: 0; - bottom: 0; + // position: absolute; + // top: 0; + // right: 0; + // bottom: 0; width: (40em / @font); border: none; background-color: var(--VC-BG-0); diff --git a/src/log/logCommand.svelte b/src/log/logCommand.svelte index 5fb81491..c703b650 100644 --- a/src/log/logCommand.svelte +++ b/src/log/logCommand.svelte @@ -1,10 +1,9 @@
- -
    {#if promptedList.length > 0}
  • Close
  • @@ -252,10 +249,11 @@
{/if}
+ +
-