Skip to content

Commit fd726a7

Browse files
committed
feat: semi
1 parent 9b75bb8 commit fd726a7

5 files changed

Lines changed: 114 additions & 98 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656
"postcss-rtlcss": "^6.0.0",
5757
"puppeteer": "^25.9.0",
5858
"ts-checker-rspack-plugin": "^1.6.1",
59-
"tsx": "^4.23.12",
59+
"tsx": "^4.23.13",
6060
"typescript": "7.0.2",
6161
"unplugin-element-plus": "^0.11.2"
6262
},

src/content-script/limit/countdown/component.ts

Lines changed: 69 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
11
import { t } from '@cs/locale'
22
import { formatPeriodCommon, MILL_PER_MINUTE, MILL_PER_SECOND } from '@util/time'
33
import { mountStyle } from '../style'
4-
import { CountdownData, Dimension, HALF_SIZE, ICON_SIZE, Position, RemainingItem } from './common'
4+
import type { CountdownData, Dimension, RemainingItem } from './types'
55

66
const CONTAINER_CLS = 'countdown-container'
77
const TOOLTIP_CLS = 'tooltip'
8+
const ICON_SIZE = 40
9+
const HALF_SIZE = ICON_SIZE / 2
810
const STROKE_WIDTH = 3
911
const RADIUS = (ICON_SIZE - STROKE_WIDTH) / 2
1012
const CIRCUMFERENCE = 2 * Math.PI * RADIUS
1113

1214
type 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

1620
const 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+
2855
function 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

88120
function 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

247273
function 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

260279
function defaultPosition(): Position {
@@ -265,18 +284,11 @@ function defaultPosition(): Position {
265284
function 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
}

src/content-script/limit/countdown/index.ts

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,48 +4,51 @@ import LocationWatcher from '@cs/location-watcher'
44
import DelayCoordinator from '../manager/delay-coordinator'
55
import LimitState from '../manager/state'
66
import { VisitProcessor } from '../processor'
7-
import { TIMER_INTERVAL } from './common'
87
import { CountdownComponent } from './component'
9-
import { CountdownModel } from './model'
8+
import { CountdownState } from './state'
9+
10+
const TIMER_INTERVAL = 1000
1011

1112
export default class Countdown {
12-
#model = new CountdownModel()
13+
#enabled: boolean = false
14+
#visible: boolean = false
15+
#state = new CountdownState()
1316
#component = new CountdownComponent()
1417
#interval?: ReturnType<typeof setInterval>
1518
#onVisibleChange = () => document.visibilityState === 'visible' && this.#sync()
1619

1720
get isEffective() {
18-
return this.#model.enabled && !this.#model.limited && !this.location.isWhite
21+
return this.#enabled && !this.#visible && !this.location.isWhite
1922
}
2023

2124
constructor(private readonly location: LocationWatcher, initialOption: tt4b.option.LimitOption) {
22-
location.onCurrChange(() => {
23-
this.#model.resetTime()
24-
this.#sync()
25-
})
26-
this.#model.enabled = initialOption.limitCountdown
27-
this.#model.delayDuration = initialOption.limitDelayDuration
25+
this.#applyOption(initialOption)
2826
}
2927

3028
async init(state: LimitState, visit: VisitProcessor, delayCoord: DelayCoordinator) {
29+
this.location.onCurrChange(() => {
30+
this.#state.resetTime()
31+
this.#sync()
32+
})
33+
3134
state.onChange(reason => {
32-
this.#model.limited = !!reason
35+
this.#visible = !!reason
3336
this.#sync()
3437
})
3538

3639
visit.onChange(mills => {
37-
this.#model.visitTime = mills
40+
this.#state.visitTime = mills
3841
this.#render()
3942
})
4043

4144
delayCoord.register(() => {
42-
this.#model.onDelay()
45+
this.#state.incDelayCount()
4346
this.#render()
4447
}, 'VISIT')
4548

4649
document.addEventListener('visibilitychange', this.#onVisibleChange)
4750

48-
this.isEffective && this.#sync()
51+
this.#sync()
4952
}
5053

5154
destroy() {
@@ -56,13 +59,17 @@ export default class Countdown {
5659

5760
async fetchOption() {
5861
const option = await getOption()
59-
this.#model.delayDuration = option.limitDelayDuration
60-
this.#model.enabled = option.limitCountdown
62+
this.#applyOption(option)
6163
this.#sync()
6264
}
6365

66+
#applyOption(option: tt4b.option.LimitOption) {
67+
this.#state.delayDuration = option.limitDelayDuration
68+
this.#enabled = option.limitCountdown
69+
}
70+
6471
async #sync() {
65-
this.#model.rules = this.isEffective
72+
this.#state.rules = this.isEffective
6673
? await trySendMsg2Runtime('limit.list', { effective: true, url: this.location.url }) ?? []
6774
: []
6875

@@ -73,7 +80,7 @@ export default class Countdown {
7380
if (this.#interval) return
7481
this.#interval = setInterval(() => {
7582
if (document.hidden || !this.isEffective) return
76-
this.#model.onActiveTick()
83+
this.#state.addActiveTime(TIMER_INTERVAL)
7784
this.#render()
7885
}, TIMER_INTERVAL)
7986
}
@@ -85,8 +92,8 @@ export default class Countdown {
8592
}
8693

8794
async #render() {
88-
const data = this.#model.data
95+
const data = this.#state.data
8996
this.#component.render(data)
9097
data ? this.#startTimer() : this.#stopTimer()
9198
}
92-
}
99+
}

src/content-script/limit/countdown/model.ts renamed to src/content-script/limit/countdown/state.ts

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,32 @@
1-
import { MILL_PER_MINUTE } from '@util/time'
2-
import { CountdownData, Dimension, RemainingItem, TIMER_INTERVAL } from './common'
1+
import { MILL_PER_MINUTE, MILL_PER_SECOND } from '@util/time'
2+
import type { CountdownData, Dimension, RemainingItem } from './types'
33

44
const ALL_DIMENSIONS: Dimension[] = ['daily', 'weekly', 'visit']
55

6-
export class CountdownModel {
7-
enabled: boolean = false
8-
limited: boolean = false
6+
export class CountdownState {
97
delayDuration: number = 0
8+
#delayCount: number = 0
109
#rules: tt4b.limit.Item[] = []
1110
#visitMills: number = 0
1211
#activeMills: number = 0
13-
#delayCount: number = 0
1412

13+
/**
14+
* Calculate the milliseconds of each dimension
15+
*/
1516
#dimStrategy: Record<Dimension, (item: tt4b.limit.Item) => [total: number, used: number] | undefined> = {
16-
daily: ({ time: total, waste, delayCount }) => {
17-
if (!total) return undefined
17+
daily: ({ time, waste, delayCount }) => {
18+
if (!time) return undefined
19+
const total = time * MILL_PER_SECOND
1820
const used = waste + this.#activeMills - delayCount * this.delayDuration * MILL_PER_MINUTE
1921
return [total, used]
2022
},
21-
weekly: ({ weekly: total, weeklyWaste: waste, weeklyDelayCount: delayCount }) => {
22-
if (!total) return undefined
23+
weekly: ({ weekly, weeklyWaste: waste, weeklyDelayCount: delayCount }) => {
24+
if (!weekly) return undefined
25+
const total = weekly * MILL_PER_SECOND
2326
const used = waste + this.#activeMills - delayCount * this.delayDuration * MILL_PER_MINUTE
2427
return [total, used]
2528
},
26-
visit: ({ visitTime: total }) => total ? [total, this.#visitMills] : undefined,
29+
visit: ({ visitTime }) => visitTime ? [visitTime * MILL_PER_SECOND, this.#visitMills] : undefined,
2730
}
2831

2932
set rules(rules: tt4b.limit.Item[]) {
@@ -36,12 +39,12 @@ export class CountdownModel {
3639
this.#visitMills = val
3740
}
3841

39-
onDelay() {
42+
incDelayCount() {
4043
this.#delayCount++
4144
}
4245

43-
onActiveTick() {
44-
this.#activeMills += TIMER_INTERVAL
46+
addActiveTime(mills: number) {
47+
this.#activeMills += mills
4548
}
4649

4750
resetTime() {

src/content-script/limit/countdown/common.ts renamed to src/content-script/limit/countdown/types.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,3 @@
1-
export const ICON_SIZE = 40
2-
export const HALF_SIZE = ICON_SIZE / 2
3-
export const TIMER_INTERVAL = 1000
4-
5-
export type Position = { x: number, y: number }
6-
71
export type Dimension = 'daily' | 'weekly' | 'visit'
82

93
export type RemainingItem = {

0 commit comments

Comments
 (0)