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/src/component/recycleScroller/recycleItem.svelte b/src/component/recycleScroller/recycleItem.svelte new file mode 100644 index 00000000..f1b59d79 --- /dev/null +++ b/src/component/recycleScroller/recycleItem.svelte @@ -0,0 +1,34 @@ + + +
+ +
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..c8dc8240 --- /dev/null +++ b/src/component/recycleScroller/recycleScroller.less @@ -0,0 +1,42 @@ +.vc-scroller-viewport { + position: relative; + overflow: hidden; + height: 100%; +} + +.vc-scroller-contents { + min-height: 100%; + will-change: transform; +} + +.vc-scroller-items { + will-change: height; +} + +.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..92911861 --- /dev/null +++ b/src/component/recycleScroller/recycleScroller.svelte @@ -0,0 +1,351 @@ + + +
+
+
+ {#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..c7cf1bd1 --- /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..e46c9a5e --- /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..5a258032 --- /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..fa13d0b0 --- /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..fc962cde --- /dev/null +++ b/src/component/recycleScroller/scroll/touchTracker.ts @@ -0,0 +1,162 @@ +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) => { + 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) => { + 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) => { + 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) => { + 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) => { + e.preventDefault(); + + this._wheelDeltaX += e.deltaX + this._wheelDeltaY += e.deltaY + + this._onWheel.trigger() + }; +} + +export default TouchTracker; diff --git a/src/core/core.svelte b/src/core/core.svelte index e894daf8..ba5be259 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[]; } @@ -345,6 +346,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/view.less b/src/core/style/view.less index d7247326..18bed4c1 100644 --- a/src/core/style/view.less +++ b/src/core/style/view.less @@ -74,16 +74,21 @@ .vc-plugin-box { display: none; 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 +101,16 @@ text-align: center; } +.vc-plugin-empty { + color: var(--VC-FG-1); + font-size: (15em / @font); + height: 100%; + width: 100%; + 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..71dbd0c8 100644 --- a/src/log/log.model.ts +++ b/src/log/log.model.ts @@ -20,7 +20,8 @@ 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)` } @@ -240,8 +241,10 @@ export class VConsoleLogModel extends VConsoleModel { _id: tool.getUniqueID(), type: item.type, cmdType: opt?.cmdType, + toggle: {}, date: Date.now(), data: getLogDatasWithFormatting(item.origData || []), + repeated: 0, }; // for (let i = 0; i < item?.origData.length; i++) { // const data: IVConsoleLogData = { diff --git a/src/log/log.svelte b/src/log/log.svelte index 8ac0c4f5..0449472f 100644 --- a/src/log/log.svelte +++ b/src/log/log.svelte @@ -1,20 +1,23 @@
- {#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..6e6f478f 100644 --- a/src/log/logCommand.svelte +++ b/src/log/logCommand.svelte @@ -43,7 +43,7 @@ onDestroy(() => { Style.unuse(); }); - + /************************************* * Methods @@ -109,7 +109,7 @@ if (promptedList.length >= 100) { break; } - + const key = String(cachedObjKeys[objName][i]); const keyPattern = new RegExp('^' + keyName, 'i'); // polyfill String.startsWith if (keyPattern.test(key)) { @@ -222,8 +222,6 @@
- -
    {#if promptedList.length > 0}
  • Close
  • @@ -252,10 +250,11 @@
{/if} + +
-