11import { t } from '@cs/locale'
22import { formatPeriodCommon , MILL_PER_MINUTE , MILL_PER_SECOND } from '@util/time'
33import { mountStyle } from '../style'
4- import { CountdownData , Dimension , HALF_SIZE , ICON_SIZE , Position , RemainingItem } from './common '
4+ import type { CountdownData , Dimension , RemainingItem } from './types '
55
66const CONTAINER_CLS = 'countdown-container'
77const TOOLTIP_CLS = 'tooltip'
8+ const ICON_SIZE = 40
9+ const HALF_SIZE = ICON_SIZE / 2
810const STROKE_WIDTH = 3
911const RADIUS = ( ICON_SIZE - STROKE_WIDTH ) / 2
1012const CIRCUMFERENCE = 2 * Math . PI * RADIUS
1113
1214type Stage = 'enough' | 'warning' | 'short'
1315
14- type Edge = 'left' | 'right' | 'top' | 'bottom'
16+ type Edge = 'left' | 'right'
17+
18+ type Position = { x : number , y : number }
1519
1620const DIMENSION_LABELS : Record < Dimension , string > = {
1721 daily : t ( msg => msg . calendar . range . today ) ,
@@ -25,6 +29,29 @@ const STAGE_COLORS: Record<Stage, string> = {
2529 short : '#F56C6C' ,
2630}
2731
32+
33+
34+ abstract class VNode < State , K extends keyof HTMLElementTagNameMap > {
35+ el : HTMLElementTagNameMap [ K ]
36+
37+ constructor ( protected state : State | null = null ) {
38+ this . el = this . init ( )
39+ }
40+
41+ protected abstract init ( ) : HTMLElementTagNameMap [ K ]
42+
43+ render ( state : State ) {
44+ if ( this . sameAsCurrent ( state ) ) return
45+ this . doRender ( this . state = state )
46+ }
47+
48+ protected sameAsCurrent ( newState : State ) {
49+ return newState === this . state
50+ }
51+
52+ protected abstract doRender ( state : State ) : void
53+ }
54+
2855function getStage ( remaining : number , total : number ) : Stage {
2956 const progress = remaining / total
3057 if ( progress < 0.2 || remaining < 30 * MILL_PER_SECOND ) return 'short'
@@ -70,19 +97,24 @@ function createSvgRing() {
7097 return { svg, circle }
7198}
7299
73- function createTextEl ( ) {
74- const el = document . createElement ( 'span' )
75- mountStyle ( el , {
76- position : 'absolute' ,
77- top : '50%' ,
78- left : '50%' ,
79- transform : 'translate(-50%, -50%)' ,
80- fontSize : '12px' ,
81- fontWeight : 'bold' ,
82- color : '#303133' ,
83- userSelect : 'none' ,
84- } )
85- return el
100+ class CenterText extends VNode < string , 'span' > {
101+ protected init ( ) : HTMLSpanElement {
102+ const el = document . createElement ( 'span' )
103+ mountStyle ( el , {
104+ position : 'absolute' ,
105+ top : '50%' ,
106+ left : '50%' ,
107+ transform : 'translate(-50%, -50%)' ,
108+ fontSize : '11px' ,
109+ color : '#303133' ,
110+ userSelect : 'none' ,
111+ } )
112+ return el
113+ }
114+
115+ protected doRender ( state : string ) : void {
116+ this . el . innerText = state
117+ }
86118}
87119
88120function createTooltip ( ) {
@@ -165,8 +197,6 @@ function createIcon(): IconInstance {
165197 .pulse { animation: pulse 1.5s ease-in-out infinite; }
166198 .edge-left { transform: translateX(${ HALF_SIZE } px); }
167199 .edge-right { transform: translateX(-${ HALF_SIZE } px); }
168- .edge-top { transform: translateY(${ HALF_SIZE } px); }
169- .edge-bottom { transform: translateY(-${ HALF_SIZE } px); }
170200 .${ TOOLTIP_CLS } {
171201 position: absolute;
172202 opacity: 0;
@@ -189,20 +219,6 @@ function createIcon(): IconInstance {
189219 bottom: auto;
190220 transform: translateY(-50%);
191221 }
192- .edge-top .${ TOOLTIP_CLS } {
193- top: calc(100% + 8px);
194- bottom: auto;
195- left: 50%;
196- right: auto;
197- transform: translateX(-50%);
198- }
199- .edge-bottom .${ TOOLTIP_CLS } {
200- top: auto;
201- bottom: calc(100% + 8px);
202- left: 50%;
203- right: auto;
204- transform: translateX(-50%);
205- }
206222 `
207223 shadow . append ( style )
208224
@@ -212,21 +228,31 @@ function createIcon(): IconInstance {
212228 const { svg, circle } = createSvgRing ( )
213229 container . append ( svg )
214230
215- const textEl = createTextEl ( )
216- container . append ( textEl )
231+ const text = new CenterText ( )
232+ container . append ( text . el )
217233
218234 const tooltip = createTooltip ( )
219235 container . append ( tooltip )
220236
237+ let lastStage : Stage | null = null
238+ let lastEdge : Edge | null = null
239+
221240 const render = ( position : Position , { remaining, total, all } : CountdownData ) => {
241+ text . render ( getCenterText ( remaining ) )
222242 const stage = getStage ( remaining , total )
223- textEl . innerText = getCenterText ( remaining )
224- circle . setAttribute ( 'stroke' , STAGE_COLORS [ stage ] )
225- circle . setAttribute ( 'stroke-dashoffset' , String ( CIRCUMFERENCE * ( 1 - remaining / total ) ) )
243+ const edge = getEdge ( position )
244+ if ( stage !== lastStage ) {
245+ lastStage = stage
246+ circle . setAttribute ( 'stroke' , STAGE_COLORS [ stage ] )
247+ container . classList . toggle ( 'pulse' , stage === 'short' )
248+ }
226249
227- container . classList . toggle ( 'pulse' , stage === 'short' )
228- container . classList . remove ( 'edge-left' , 'edge-right' , 'edge-top' , 'edge-bottom' )
229- container . classList . add ( `edge-${ getEdge ( position ) } ` )
250+ circle . setAttribute ( 'stroke-dashoffset' , String ( CIRCUMFERENCE * ( 1 - remaining / total ) ) )
251+ if ( lastEdge !== edge ) {
252+ lastEdge = edge
253+ container . classList . remove ( 'edge-left' , 'edge-right' )
254+ container . classList . add ( `edge-${ edge } ` )
255+ }
230256
231257 updateTooltip ( tooltip , all )
232258 }
@@ -245,16 +271,9 @@ function clampPosition({ x, y }: Position): Position {
245271}
246272
247273function getEdge ( position : Position ) : Edge {
248- const { w, h } = getViewportSize ( )
249- let { x, y } = clampPosition ( position )
250-
251- const distances : Record < Edge , number > = {
252- left : x , right : w - x ,
253- top : y , bottom : h - y ,
254- }
255- const minDistance = Object . entries ( distances )
256- . sort ( ( a , b ) => a [ 1 ] - b [ 1 ] ) [ 0 ]
257- return ( minDistance ?. [ 0 ] ?? 'right' ) as Edge
274+ const { w } = getViewportSize ( )
275+ let { x } = clampPosition ( position )
276+ return x < w - x ? 'left' : 'right'
258277}
259278
260279function defaultPosition ( ) : Position {
@@ -265,18 +284,11 @@ function defaultPosition(): Position {
265284function snapPosition ( position : Position ) : Position {
266285 const { w, h } = getViewportSize ( )
267286 const { x, y } = clampPosition ( position )
268- const distances : Record < Edge , number > = {
269- left : x , right : w - x ,
270- top : y , bottom : h - y ,
271- }
272- const edge : Edge = ( Object . entries ( distances )
273- . sort ( ( a , b ) => a [ 1 ] - b [ 1 ] ) [ 0 ] ?. [ 0 ] ?? 'right' ) as Edge
287+ const edge = x < w - x ? 'left' : 'right'
274288 const clamp = ( value : number , max : number ) => Math . max ( HALF_SIZE , Math . min ( max - HALF_SIZE , value ) )
275289 const strategies : Record < Edge , ( ) => Position > = {
276290 left : ( ) => ( { x : 0 , y : clamp ( y , h ) } ) ,
277291 right : ( ) => ( { x : w , y : clamp ( y , h ) } ) ,
278- top : ( ) => ( { x : clamp ( x , w ) , y : 0 } ) ,
279- bottom : ( ) => ( { x : clamp ( x , y ) , y : h } ) ,
280292 }
281293 return strategies [ edge ] ( )
282294}
0 commit comments