From 41584ee155e825a67f248c070eee41eaf6e0ba09 Mon Sep 17 00:00:00 2001 From: chyizheng Date: Fri, 30 Sep 2022 16:49:37 +0800 Subject: [PATCH 01/19] feat: recycel scroller --- .../recycleScroller/recycleScroller.less | 25 + .../recycleScroller/recycleScroller.svelte | 507 ++++++++++++++++++ .../recycleScroller/scroll/friction.ts | 43 ++ .../recycleScroller/scroll/scroll.ts | 86 +++ .../recycleScroller/scroll/scrollHandler.ts | 174 ++++++ .../recycleScroller/scroll/spring.ts | 132 +++++ .../recycleScroller/scroll/touchTracker.ts | 165 ++++++ src/core/style/view.less | 23 +- src/log/log.less | 2 +- src/log/log.model.ts | 2 + src/log/log.svelte | 42 +- src/log/logCommand.less | 18 +- src/log/logCommand.svelte | 10 +- src/log/logRow.svelte | 9 +- src/log/logTree.svelte | 21 +- src/log/logValue.svelte | 22 +- 16 files changed, 1209 insertions(+), 72 deletions(-) create mode 100644 src/component/recycleScroller/recycleScroller.less create mode 100644 src/component/recycleScroller/recycleScroller.svelte create mode 100644 src/component/recycleScroller/scroll/friction.ts create mode 100644 src/component/recycleScroller/scroll/scroll.ts create mode 100644 src/component/recycleScroller/scroll/scrollHandler.ts create mode 100644 src/component/recycleScroller/scroll/spring.ts create mode 100644 src/component/recycleScroller/scroll/touchTracker.ts diff --git a/src/component/recycleScroller/recycleScroller.less b/src/component/recycleScroller/recycleScroller.less new file mode 100644 index 00000000..9285d17e --- /dev/null +++ b/src/component/recycleScroller/recycleScroller.less @@ -0,0 +1,25 @@ +.vc-scroller-viewport { + position: relative; + overflow: hidden; + display: flex; + flex-direction: column; + height: 100%; +} + +.vc-scroller-contents { + flex: 1; + display: flex; + flex-direction: column; +} + +.vc-scroller-header { + +} + +.vc-scroller-items { + flex: 1; +} + +.vc-scroller-footer { + +} diff --git a/src/component/recycleScroller/recycleScroller.svelte b/src/component/recycleScroller/recycleScroller.svelte new file mode 100644 index 00000000..78c2affa --- /dev/null +++ b/src/component/recycleScroller/recycleScroller.svelte @@ -0,0 +1,507 @@ + + +
+
+ {#if $$slots.header} +
+ +
+ {/if} +
+ {#if items.length} + {#each visible as row, i (row.key)} +
+ Missing template +
+ {/each} + {:else} + + {/if} +
+ {#if $$slots.footer} + + {/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/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..06dc8f8d --- /dev/null +++ b/src/component/recycleScroller/scroll/scrollHandler.ts @@ -0,0 +1,174 @@ +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 _scroll: Scroll; + 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._scroll = new Scroll(getExtent, false); + } + + 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._scroll; + 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._scroll; + 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) { + const dx = -position - this._position; + this._startPosition += dx; + this._position += dx + const pos = this._position; + + this._updatePosition(-pos); + + const scroll = this._scroll; + const t = Date.now(); + if (!scroll.done(t)) { + const dx = scroll.dx(t); + scroll.set(pos, dx, t); + } + } +} + +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..387ed42c --- /dev/null +++ b/src/component/recycleScroller/scroll/touchTracker.ts @@ -0,0 +1,165 @@ +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, + }; + } + } + } + + private _onTouchMove = throttleRAF(() => { + 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.trigger() + }; + + handleTouchEnd = (e: TouchEvent) => { + e.preventDefault(); + + const delta = this._getTouchDelta(e); + if (delta === null) return; + + this._onTouchMove.cancel() + + 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._onTouchMove.cancel() + + 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/style/view.less b/src/core/style/view.less index d7247326..1b97f08a 100644 --- a/src/core/style/view.less +++ b/src/core/style/view.less @@ -57,8 +57,9 @@ // content .vc-content { background-color: var(--VC-BG-2); - overflow-x: hidden; - overflow-y: auto; + overflow: hidden; + // overflow-x: hidden; + // overflow-y: auto; position: absolute; top: (40em / @font); left: 0; @@ -74,16 +75,18 @@ .vc-plugin-box { display: none; position: relative; - min-height: 100%; + height: 100%; } .vc-plugin-box.vc-actived { display: block; } .vc-plugin-content { - padding-bottom: (39em / @font) * 2; + display: flex; + width: 100%; + height: 100%; + 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 +99,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/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..b951d05b 100644 --- a/src/log/log.model.ts +++ b/src/log/log.model.ts @@ -21,6 +21,7 @@ export interface IVConsoleLog { type: IConsoleLogMethod; cmdType?: 'input' | 'output'; repeated?: number; + toggle: Record; date: number; data: IVConsoleLogData[]; // the `args: any[]` of `console.log(...args)` } @@ -240,6 +241,7 @@ export class VConsoleLogModel extends VConsoleModel { _id: tool.getUniqueID(), type: item.type, cmdType: opt?.cmdType, + toggle: {}, date: Date.now(), data: getLogDatasWithFormatting(item.origData || []), }; diff --git a/src/log/log.svelte b/src/log/log.svelte index 8ac0c4f5..e1fdf56a 100644 --- a/src/log/log.svelte +++ b/src/log/log.svelte @@ -5,7 +5,8 @@ import LogRow from './logRow.svelte'; import LogCommand from './logCommand.svelte'; import Style from './log.less'; - import type { IConsoleLogMethod } from './log.model'; + import type { IConsoleLogMethod, IVConsoleLog } from './log.model'; + import RecycleScroller from '../component/recycleScroller/recycleScroller.svelte'; export let pluginId: string = 'default'; export let showCmd: boolean = false; @@ -24,6 +25,18 @@ } } + let logList: IVConsoleLog[] = [] + + $: { + logList = $store.logList.filter(log => + // filterType + (filterType === 'all' || filterType === log.type) + && + // filterText + (filterText === '' || isMatchedFilterText(log, filterText)) + ) + } + onMount(() => { Style.use(); }); @@ -38,24 +51,13 @@
- {#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/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} + +
    -