@tom2012/dsh-pomodoro-overlay 0.0.0-stage → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +127 -2
- package/client/client.js +236 -0
- package/cordis.patch.yml +4 -0
- package/docs/architecture.md +84 -0
- package/docs/design.md +72 -0
- package/docs/verification.md +102 -0
- package/lib/index.js +28 -0
- package/package.json +77 -4
- package/src/browser-store.js +251 -0
- package/src/chat-geometry.js +49 -0
- package/src/client.js +235 -0
- package/src/font.js +18 -0
- package/src/settings.js +329 -0
- package/src/styles.js +230 -0
- package/src/timer-core.js +254 -0
- package/tests/browser-store.test.js +362 -0
- package/tests/chat-geometry.test.js +84 -0
- package/tests/client-bundle.test.js +106 -0
- package/tests/font.test.js +21 -0
- package/tests/host-config.test.js +67 -0
- package/tests/timer-core.test.js +205 -0
- package/tools/build.mjs +37 -0
- package/tools/check-package.mjs +50 -0
package/src/client.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
import { createPomodoroStore } from './browser-store.js'
|
|
4
|
+
import { findChatOverlay } from './chat-geometry.js'
|
|
5
|
+
import { fontStack } from './font.js'
|
|
6
|
+
import { CSS } from './styles.js'
|
|
7
|
+
import { PomodoroSettings } from './settings.js'
|
|
8
|
+
import { FOCUS } from './timer-core.js'
|
|
9
|
+
|
|
10
|
+
const h = React.createElement
|
|
11
|
+
const PACKAGE_NAME = '@tom2012/dsh-pomodoro-overlay'
|
|
12
|
+
const FORM_ID = 'pomodoro-overlay'
|
|
13
|
+
|
|
14
|
+
function phaseName(phase) {
|
|
15
|
+
return phase === FOCUS ? '专注' : '休息'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function fitCountdown(root, number) {
|
|
19
|
+
if (!root || !number) return
|
|
20
|
+
if (root.hidden || root.clientWidth === 0 || root.clientHeight === 0) {
|
|
21
|
+
root.__dpoFitKey = ''
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
const computed = getComputedStyle(number)
|
|
25
|
+
const text = number.textContent || '00:00'
|
|
26
|
+
const key = [root.clientWidth, root.clientHeight, text, computed.fontFamily, computed.fontWeight].join('|')
|
|
27
|
+
if (root.__dpoFitKey === key) return
|
|
28
|
+
root.__dpoFitKey = key
|
|
29
|
+
|
|
30
|
+
const availableWidth = Math.max(32, root.clientWidth - 32)
|
|
31
|
+
const availableHeight = Math.max(32, root.clientHeight - 64)
|
|
32
|
+
let widthAt100 = availableWidth / 3
|
|
33
|
+
const canvas = document.createElement('canvas')
|
|
34
|
+
const context = canvas.getContext('2d')
|
|
35
|
+
if (context) {
|
|
36
|
+
context.font = `${computed.fontWeight} 100px ${computed.fontFamily}`
|
|
37
|
+
// The display face uses -0.065em tracking; include it in the fit estimate.
|
|
38
|
+
widthAt100 = Math.max(1, context.measureText(text).width - (text.length - 1) * 6.5)
|
|
39
|
+
}
|
|
40
|
+
const size = Math.max(24, Math.floor(Math.min(
|
|
41
|
+
availableHeight,
|
|
42
|
+
availableWidth * 100 / widthAt100,
|
|
43
|
+
)))
|
|
44
|
+
number.style.fontSize = `${size}px`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function ActiveOverlay({ snapshot }) {
|
|
48
|
+
const rootRef = React.useRef(null)
|
|
49
|
+
const numberRef = React.useRef(null)
|
|
50
|
+
|
|
51
|
+
React.useLayoutEffect(() => {
|
|
52
|
+
fitCountdown(rootRef.current, numberRef.current)
|
|
53
|
+
}, [snapshot.text, snapshot.config.fontFamily])
|
|
54
|
+
|
|
55
|
+
React.useEffect(() => {
|
|
56
|
+
const root = rootRef.current
|
|
57
|
+
if (!root) return undefined
|
|
58
|
+
let disposed = false
|
|
59
|
+
let frame = null
|
|
60
|
+
let mutationTimer = null
|
|
61
|
+
let observedChat = null
|
|
62
|
+
let observedComposer = null
|
|
63
|
+
|
|
64
|
+
const geometryObserver = typeof ResizeObserver === 'function'
|
|
65
|
+
? new ResizeObserver(() => scheduleFrame())
|
|
66
|
+
: null
|
|
67
|
+
const numberObserver = typeof ResizeObserver === 'function'
|
|
68
|
+
? new ResizeObserver(() => fitCountdown(root, numberRef.current))
|
|
69
|
+
: null
|
|
70
|
+
|
|
71
|
+
function update() {
|
|
72
|
+
if (disposed) return
|
|
73
|
+
const match = findChatOverlay(document)
|
|
74
|
+
const nextChat = match?.chat ?? null
|
|
75
|
+
const nextComposer = nextChat?.querySelector('[data-composer-seat]') ?? null
|
|
76
|
+
if (nextChat !== observedChat || nextComposer !== observedComposer) {
|
|
77
|
+
observedChat = nextChat
|
|
78
|
+
observedComposer = nextComposer
|
|
79
|
+
geometryObserver?.disconnect()
|
|
80
|
+
if (observedChat) geometryObserver?.observe(observedChat)
|
|
81
|
+
if (observedComposer) geometryObserver?.observe(observedComposer)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!match) {
|
|
85
|
+
root.hidden = true
|
|
86
|
+
root.dataset.dpoGeometry = 'hidden'
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
const { rect } = match
|
|
90
|
+
root.style.left = `${rect.left}px`
|
|
91
|
+
root.style.top = `${rect.top}px`
|
|
92
|
+
root.style.width = `${rect.width}px`
|
|
93
|
+
root.style.height = `${rect.height}px`
|
|
94
|
+
root.hidden = false
|
|
95
|
+
root.dataset.dpoGeometry = 'active'
|
|
96
|
+
fitCountdown(root, numberRef.current)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function scheduleFrame() {
|
|
100
|
+
if (disposed || frame !== null) return
|
|
101
|
+
frame = requestAnimationFrame(() => {
|
|
102
|
+
frame = null
|
|
103
|
+
update()
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function scheduleMutationCheck() {
|
|
108
|
+
if (disposed || mutationTimer !== null) return
|
|
109
|
+
// Streaming messages can mutate often; rebind at most once per 160ms.
|
|
110
|
+
mutationTimer = window.setTimeout(() => {
|
|
111
|
+
mutationTimer = null
|
|
112
|
+
update()
|
|
113
|
+
}, 160)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const mutationObserver = typeof MutationObserver === 'function'
|
|
117
|
+
? new MutationObserver(scheduleMutationCheck)
|
|
118
|
+
: null
|
|
119
|
+
mutationObserver?.observe(document.body, {
|
|
120
|
+
childList: true,
|
|
121
|
+
subtree: true,
|
|
122
|
+
attributes: true,
|
|
123
|
+
attributeFilter: ['data-conversation-content', 'data-chat-flow', 'data-composer-seat', 'hidden', 'inert'],
|
|
124
|
+
})
|
|
125
|
+
numberObserver?.observe(root)
|
|
126
|
+
window.addEventListener('resize', scheduleFrame)
|
|
127
|
+
window.addEventListener('scroll', scheduleFrame, true)
|
|
128
|
+
update()
|
|
129
|
+
|
|
130
|
+
return () => {
|
|
131
|
+
disposed = true
|
|
132
|
+
geometryObserver?.disconnect()
|
|
133
|
+
numberObserver?.disconnect()
|
|
134
|
+
mutationObserver?.disconnect()
|
|
135
|
+
if (frame !== null) cancelAnimationFrame(frame)
|
|
136
|
+
if (mutationTimer !== null) clearTimeout(mutationTimer)
|
|
137
|
+
window.removeEventListener('resize', scheduleFrame)
|
|
138
|
+
window.removeEventListener('scroll', scheduleFrame, true)
|
|
139
|
+
}
|
|
140
|
+
}, [])
|
|
141
|
+
|
|
142
|
+
const label = phaseName(snapshot.phase)
|
|
143
|
+
const status = snapshot.running ? label : `${label} · 已暂停`
|
|
144
|
+
const appearance = snapshot.phase === FOCUS
|
|
145
|
+
? { color: snapshot.config.focusColor, opacity: snapshot.config.focusOpacity }
|
|
146
|
+
: { color: snapshot.config.breakColor, opacity: snapshot.config.breakOpacity }
|
|
147
|
+
|
|
148
|
+
return h('div', {
|
|
149
|
+
ref: rootRef,
|
|
150
|
+
className: 'dpo-overlay',
|
|
151
|
+
hidden: true,
|
|
152
|
+
role: 'group',
|
|
153
|
+
'aria-label': `番茄钟,${status}`,
|
|
154
|
+
'data-dpo-overlay': '',
|
|
155
|
+
'data-phase': snapshot.phase,
|
|
156
|
+
'data-running': String(snapshot.running),
|
|
157
|
+
'data-dpo-geometry': 'hidden',
|
|
158
|
+
}, [
|
|
159
|
+
h('div', {
|
|
160
|
+
key: 'number',
|
|
161
|
+
ref: numberRef,
|
|
162
|
+
className: 'dpo-number',
|
|
163
|
+
'data-dpo-number': '',
|
|
164
|
+
'data-dpo-color': appearance.color,
|
|
165
|
+
'data-dpo-opacity': String(appearance.opacity),
|
|
166
|
+
'data-dpo-font-family': snapshot.config.fontFamily,
|
|
167
|
+
'aria-hidden': 'true',
|
|
168
|
+
style: {
|
|
169
|
+
color: appearance.color,
|
|
170
|
+
opacity: appearance.opacity,
|
|
171
|
+
fontFamily: fontStack(snapshot.config.fontFamily),
|
|
172
|
+
},
|
|
173
|
+
}, snapshot.text),
|
|
174
|
+
h('span', {
|
|
175
|
+
key: 'accessible-time',
|
|
176
|
+
className: 'dpo-sr-only',
|
|
177
|
+
role: 'timer',
|
|
178
|
+
'aria-label': `${status},剩余 ${snapshot.text}`,
|
|
179
|
+
}, `${status},剩余 ${snapshot.text}`),
|
|
180
|
+
])
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function PomodoroOverlay({ store }) {
|
|
184
|
+
const subscribe = React.useCallback((listener) => store.subscribe(listener), [store])
|
|
185
|
+
const getSnapshot = React.useCallback(() => store.getSnapshot(), [store])
|
|
186
|
+
const snapshot = React.useSyncExternalStore(subscribe, getSnapshot)
|
|
187
|
+
if (!snapshot.active) return null
|
|
188
|
+
return h(ActiveOverlay, { snapshot })
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export const name = PACKAGE_NAME
|
|
192
|
+
export const inject = ['slots', 'configForms']
|
|
193
|
+
|
|
194
|
+
export function apply(ctx) {
|
|
195
|
+
const form = ctx.configForms.get(FORM_ID)
|
|
196
|
+
const store = createPomodoroStore()
|
|
197
|
+
|
|
198
|
+
ctx.effect(() => {
|
|
199
|
+
const syncConfig = () => {
|
|
200
|
+
const snapshot = form.getSnapshot()
|
|
201
|
+
if (snapshot.status !== 'ready' || !snapshot.value) return
|
|
202
|
+
try {
|
|
203
|
+
store.setConfig(snapshot.value)
|
|
204
|
+
store.start()
|
|
205
|
+
} catch (error) {
|
|
206
|
+
ctx.logger?.error?.('Pomodoro Overlay could not accept its configuration', error)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const unsubscribe = form.subscribe(syncConfig)
|
|
210
|
+
syncConfig()
|
|
211
|
+
return () => {
|
|
212
|
+
unsubscribe()
|
|
213
|
+
store.dispose()
|
|
214
|
+
}
|
|
215
|
+
}, 'pomodoro-overlay: native configuration and timer')
|
|
216
|
+
|
|
217
|
+
ctx.effect(() => {
|
|
218
|
+
const style = document.createElement('style')
|
|
219
|
+
style.setAttribute('data-dpo-style', '')
|
|
220
|
+
style.setAttribute('data-plugin', PACKAGE_NAME)
|
|
221
|
+
style.textContent = CSS
|
|
222
|
+
document.head.appendChild(style)
|
|
223
|
+
return () => style.remove()
|
|
224
|
+
}, 'pomodoro-overlay: styles')
|
|
225
|
+
|
|
226
|
+
ctx.slots.inject('shell.overlay', () => ctx.slots.register(
|
|
227
|
+
{ name: 'shell.overlay', id: PACKAGE_NAME, order: -100, label: '番茄钟' },
|
|
228
|
+
() => h(PomodoroOverlay, { store }),
|
|
229
|
+
))
|
|
230
|
+
|
|
231
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register(
|
|
232
|
+
{ name: 'settings.section', id: 'pomodoro', order: 30, label: '番茄钟' },
|
|
233
|
+
() => h(PomodoroSettings, { form, store }),
|
|
234
|
+
))
|
|
235
|
+
}
|
package/src/font.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export const FONT_OPTIONS = Object.freeze([
|
|
2
|
+
{ value: '等线', label: 'DengXian(默认)', stack: '"DengXian", "等线", sans-serif' },
|
|
3
|
+
{ value: 'system-ui', label: '系统无衬线', stack: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' },
|
|
4
|
+
{ value: 'monospace', label: '等宽字体', stack: 'ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, monospace' },
|
|
5
|
+
{ value: 'Arial', label: 'Arial', stack: 'Arial, Helvetica, sans-serif' },
|
|
6
|
+
{ value: 'Courier New', label: 'Courier New', stack: '"Courier New", Courier, monospace' },
|
|
7
|
+
{ value: 'Georgia', label: 'Georgia', stack: 'Georgia, "Times New Roman", serif' },
|
|
8
|
+
])
|
|
9
|
+
|
|
10
|
+
/** Resolve a single installed font name to a safe local-only CSS font stack. */
|
|
11
|
+
export function fontStack(family) {
|
|
12
|
+
if (typeof family !== 'string' || family.trim() === '') return 'sans-serif'
|
|
13
|
+
const normalized = family.trim()
|
|
14
|
+
const known = FONT_OPTIONS.find((option) => option.value === normalized)
|
|
15
|
+
if (known) return known.stack
|
|
16
|
+
const escaped = normalized.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
|
17
|
+
return `"${escaped}", sans-serif`
|
|
18
|
+
}
|
package/src/settings.js
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
import { FONT_OPTIONS, fontStack } from './font.js'
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_CONFIG,
|
|
6
|
+
FOCUS,
|
|
7
|
+
isValidColor,
|
|
8
|
+
isValidFontFamily,
|
|
9
|
+
isValidMinutes,
|
|
10
|
+
parseConfig,
|
|
11
|
+
} from './timer-core.js'
|
|
12
|
+
|
|
13
|
+
const h = React.createElement
|
|
14
|
+
const MINUTES_FIELDS = ['focusMinutes', 'breakMinutes']
|
|
15
|
+
const COLOR_FIELDS = ['focusColor', 'breakColor']
|
|
16
|
+
const OPACITY_FIELDS = ['focusOpacity', 'breakOpacity']
|
|
17
|
+
|
|
18
|
+
function phaseName(phase) {
|
|
19
|
+
return phase === FOCUS ? '专注' : '休息'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function draftFromConfig(value = DEFAULT_CONFIG) {
|
|
23
|
+
const source = { ...DEFAULT_CONFIG, ...value }
|
|
24
|
+
return {
|
|
25
|
+
focusMinutes: String(source.focusMinutes),
|
|
26
|
+
breakMinutes: String(source.breakMinutes),
|
|
27
|
+
focusColor: source.focusColor,
|
|
28
|
+
breakColor: source.breakColor,
|
|
29
|
+
focusOpacity: String(Math.round(source.focusOpacity * 100)),
|
|
30
|
+
breakOpacity: String(Math.round(source.breakOpacity * 100)),
|
|
31
|
+
fontFamily: source.fontFamily,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isValidPercent(value) {
|
|
36
|
+
if (typeof value === 'string' && value.trim() === '') return false
|
|
37
|
+
const parsed = Number(value)
|
|
38
|
+
return Number.isInteger(parsed) && parsed >= 0 && parsed <= 100
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function configFromDraft(draft) {
|
|
42
|
+
if (!MINUTES_FIELDS.every((field) => isValidMinutes(draft[field]))) {
|
|
43
|
+
throw new RangeError('工作和休息时长均需为 1–240 之间的整数分钟。')
|
|
44
|
+
}
|
|
45
|
+
if (!COLOR_FIELDS.every((field) => isValidColor(draft[field]))) {
|
|
46
|
+
throw new TypeError('颜色需使用 #RRGGBB 格式。')
|
|
47
|
+
}
|
|
48
|
+
if (!OPACITY_FIELDS.every((field) => isValidPercent(draft[field]))) {
|
|
49
|
+
throw new RangeError('数字不透明度需为 0–100 的整数百分比。')
|
|
50
|
+
}
|
|
51
|
+
if (!isValidFontFamily(draft.fontFamily.trim())) {
|
|
52
|
+
throw new TypeError('请输入一个本机字体名称(1–80 个字符,不包含引号或逗号)。')
|
|
53
|
+
}
|
|
54
|
+
return parseConfig({
|
|
55
|
+
focusMinutes: Number(draft.focusMinutes),
|
|
56
|
+
breakMinutes: Number(draft.breakMinutes),
|
|
57
|
+
focusColor: draft.focusColor,
|
|
58
|
+
breakColor: draft.breakColor,
|
|
59
|
+
focusOpacity: Number(draft.focusOpacity) / 100,
|
|
60
|
+
breakOpacity: Number(draft.breakOpacity) / 100,
|
|
61
|
+
fontFamily: draft.fontFamily.trim(),
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function useFormSnapshot(form) {
|
|
66
|
+
const subscribe = React.useCallback((listener) => form.subscribe(listener), [form])
|
|
67
|
+
const getSnapshot = React.useCallback(() => form.getSnapshot(), [form])
|
|
68
|
+
return React.useSyncExternalStore(subscribe, getSnapshot)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function PomodoroSettings({ form, store }) {
|
|
72
|
+
const formSnapshot = useFormSnapshot(form)
|
|
73
|
+
const timerSnapshot = useFormSnapshot(store)
|
|
74
|
+
const [draft, setDraft] = React.useState(() => draftFromConfig(form.getSnapshot().value))
|
|
75
|
+
const [baseRevision, setBaseRevision] = React.useState(form.getSnapshot().revision)
|
|
76
|
+
const [dirty, setDirty] = React.useState(false)
|
|
77
|
+
const [saving, setSaving] = React.useState(false)
|
|
78
|
+
const [error, setError] = React.useState('')
|
|
79
|
+
const [notice, setNotice] = React.useState('')
|
|
80
|
+
const alive = React.useRef(true)
|
|
81
|
+
|
|
82
|
+
React.useEffect(() => {
|
|
83
|
+
alive.current = true
|
|
84
|
+
return () => { alive.current = false }
|
|
85
|
+
}, [])
|
|
86
|
+
|
|
87
|
+
const loadLatest = React.useCallback((clearFeedback = true) => {
|
|
88
|
+
const latest = form.getSnapshot()
|
|
89
|
+
if (latest.status === 'ready' && latest.value) {
|
|
90
|
+
setDraft(draftFromConfig(latest.value))
|
|
91
|
+
setBaseRevision(latest.revision)
|
|
92
|
+
setDirty(false)
|
|
93
|
+
if (clearFeedback) {
|
|
94
|
+
setError('')
|
|
95
|
+
setNotice('')
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}, [form])
|
|
99
|
+
|
|
100
|
+
React.useEffect(() => {
|
|
101
|
+
if (!dirty && formSnapshot.status === 'ready' && formSnapshot.value) loadLatest(false)
|
|
102
|
+
}, [formSnapshot, dirty, loadLatest])
|
|
103
|
+
|
|
104
|
+
function change(field, value) {
|
|
105
|
+
setDraft((current) => ({ ...current, [field]: value }))
|
|
106
|
+
setDirty(true)
|
|
107
|
+
setError('')
|
|
108
|
+
setNotice('')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function save(event) {
|
|
112
|
+
event.preventDefault()
|
|
113
|
+
if (saving || !dirty || !formSnapshot.writable) return
|
|
114
|
+
let next
|
|
115
|
+
try {
|
|
116
|
+
next = configFromDraft(draft)
|
|
117
|
+
} catch (validationError) {
|
|
118
|
+
setError(validationError.message)
|
|
119
|
+
setNotice('')
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
setSaving(true)
|
|
123
|
+
setError('')
|
|
124
|
+
setNotice('')
|
|
125
|
+
try {
|
|
126
|
+
const accepted = await form.mutate([
|
|
127
|
+
{ op: 'set', path: ['focusMinutes'], value: next.focusMinutes },
|
|
128
|
+
{ op: 'set', path: ['breakMinutes'], value: next.breakMinutes },
|
|
129
|
+
{ op: 'set', path: ['focusColor'], value: next.focusColor },
|
|
130
|
+
{ op: 'set', path: ['breakColor'], value: next.breakColor },
|
|
131
|
+
{ op: 'set', path: ['focusOpacity'], value: next.focusOpacity },
|
|
132
|
+
{ op: 'set', path: ['breakOpacity'], value: next.breakOpacity },
|
|
133
|
+
{ op: 'set', path: ['fontFamily'], value: next.fontFamily },
|
|
134
|
+
], baseRevision)
|
|
135
|
+
if (!alive.current) return
|
|
136
|
+
if (accepted) {
|
|
137
|
+
setDirty(false)
|
|
138
|
+
setNotice('已保存。字体、颜色和不透明度已应用;新时长从下一阶段生效。')
|
|
139
|
+
} else {
|
|
140
|
+
setError('设置已在其他页面变化,本次未覆盖。草稿已保留,请核对最新值后重试。')
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
143
|
+
if (alive.current) setError('保存失败,请检查连接后重试。草稿已保留。')
|
|
144
|
+
} finally {
|
|
145
|
+
if (alive.current) setSaving(false)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const ready = formSnapshot.status === 'ready' && Boolean(formSnapshot.value)
|
|
150
|
+
const disabled = !ready || !formSnapshot.writable || saving
|
|
151
|
+
|
|
152
|
+
function field(name, title, description, control) {
|
|
153
|
+
return h('div', { className: 'dpo-field', key: name }, [
|
|
154
|
+
h('div', { className: 'dpo-field-copy', key: 'copy' }, [
|
|
155
|
+
h('label', { htmlFor: `dpo-settings-${name}`, key: 'label' }, title),
|
|
156
|
+
h('span', { className: 'dpo-hint', id: `dpo-settings-${name}-hint`, key: 'hint' }, description),
|
|
157
|
+
]),
|
|
158
|
+
control,
|
|
159
|
+
])
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function phaseCard(phase) {
|
|
163
|
+
const minutes = `${phase}Minutes`
|
|
164
|
+
const color = `${phase}Color`
|
|
165
|
+
const opacity = `${phase}Opacity`
|
|
166
|
+
const label = phaseName(phase)
|
|
167
|
+
const previewOpacity = isValidPercent(draft[opacity]) ? Number(draft[opacity]) / 100 : 0
|
|
168
|
+
return h('fieldset', {
|
|
169
|
+
className: 'dpo-phase-card',
|
|
170
|
+
'data-dpo-phase-settings': phase,
|
|
171
|
+
key: phase,
|
|
172
|
+
disabled,
|
|
173
|
+
}, [
|
|
174
|
+
h('legend', { key: 'legend' }, label),
|
|
175
|
+
h('p', { className: 'dpo-hint', key: 'phase-hint' },
|
|
176
|
+
phase === FOCUS ? '工作阶段的周期与大数字外观' : '休息阶段的周期与大数字外观'),
|
|
177
|
+
field(minutes, `${label}时长`, '1–240 分钟', h('input', {
|
|
178
|
+
key: 'input',
|
|
179
|
+
id: `dpo-settings-${minutes}`,
|
|
180
|
+
className: 'dpo-input',
|
|
181
|
+
'data-dpo-setting': minutes,
|
|
182
|
+
type: 'number',
|
|
183
|
+
min: 1,
|
|
184
|
+
max: 240,
|
|
185
|
+
step: 1,
|
|
186
|
+
inputMode: 'numeric',
|
|
187
|
+
value: draft[minutes],
|
|
188
|
+
onChange: (event) => change(minutes, event.currentTarget.value),
|
|
189
|
+
'aria-describedby': `dpo-settings-${minutes}-hint`,
|
|
190
|
+
})),
|
|
191
|
+
field(color, '数字颜色', '六位十六进制颜色', h('div', { className: 'dpo-color-control', key: 'input' }, [
|
|
192
|
+
h('input', {
|
|
193
|
+
key: 'color',
|
|
194
|
+
id: `dpo-settings-${color}`,
|
|
195
|
+
className: 'dpo-color-input',
|
|
196
|
+
'data-dpo-setting': color,
|
|
197
|
+
type: 'color',
|
|
198
|
+
value: draft[color],
|
|
199
|
+
onChange: (event) => change(color, event.currentTarget.value),
|
|
200
|
+
'aria-describedby': `dpo-settings-${color}-hint`,
|
|
201
|
+
}),
|
|
202
|
+
h('code', { key: 'value', className: 'dpo-color-value' }, draft[color].toUpperCase()),
|
|
203
|
+
])),
|
|
204
|
+
field(opacity, '数字不透明度', '0% 完全透明,100% 不透明', h('div', { className: 'dpo-range-control', key: 'input' }, [
|
|
205
|
+
h('input', {
|
|
206
|
+
key: 'range',
|
|
207
|
+
id: `dpo-settings-${opacity}`,
|
|
208
|
+
className: 'dpo-range',
|
|
209
|
+
'data-dpo-setting': opacity,
|
|
210
|
+
type: 'range',
|
|
211
|
+
min: 0,
|
|
212
|
+
max: 100,
|
|
213
|
+
step: 1,
|
|
214
|
+
value: draft[opacity],
|
|
215
|
+
onChange: (event) => change(opacity, event.currentTarget.value),
|
|
216
|
+
'aria-valuetext': `${draft[opacity]}%`,
|
|
217
|
+
'aria-describedby': `dpo-settings-${opacity}-hint`,
|
|
218
|
+
}),
|
|
219
|
+
h('output', {
|
|
220
|
+
key: 'output',
|
|
221
|
+
className: 'dpo-output',
|
|
222
|
+
htmlFor: `dpo-settings-${opacity}`,
|
|
223
|
+
'data-dpo-opacity-value': phase,
|
|
224
|
+
}, `${draft[opacity]}%`),
|
|
225
|
+
])),
|
|
226
|
+
h('div', {
|
|
227
|
+
className: 'dpo-preview',
|
|
228
|
+
'data-dpo-preview': phase,
|
|
229
|
+
key: 'preview',
|
|
230
|
+
style: {
|
|
231
|
+
'--dpo-preview-color': draft[color],
|
|
232
|
+
'--dpo-preview-opacity': previewOpacity,
|
|
233
|
+
'--dpo-preview-font': fontStack(draft.fontFamily),
|
|
234
|
+
},
|
|
235
|
+
}, [
|
|
236
|
+
h('span', { className: 'dpo-hint', key: 'label' }, '草稿预览'),
|
|
237
|
+
h('span', { className: 'dpo-preview-number', 'aria-hidden': 'true', key: 'number' },
|
|
238
|
+
phase === FOCUS ? '25:00' : '05:00'),
|
|
239
|
+
]),
|
|
240
|
+
])
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function fontCard() {
|
|
244
|
+
return h('fieldset', {
|
|
245
|
+
className: 'dpo-phase-card',
|
|
246
|
+
'data-dpo-font-settings': '',
|
|
247
|
+
key: 'font',
|
|
248
|
+
disabled,
|
|
249
|
+
}, [
|
|
250
|
+
h('legend', { key: 'legend' }, '时钟字体'),
|
|
251
|
+
h('p', { className: 'dpo-hint', key: 'font-hint' }, '专注与休息共用;只使用本机字体,不发起字体下载。'),
|
|
252
|
+
field('fontFamily', '字体名称', '默认等线(DengXian),可输入其他本机字体', h('div', {
|
|
253
|
+
className: 'dpo-font-control',
|
|
254
|
+
key: 'input',
|
|
255
|
+
}, [
|
|
256
|
+
h('input', {
|
|
257
|
+
key: 'font',
|
|
258
|
+
id: 'dpo-settings-fontFamily',
|
|
259
|
+
className: 'dpo-input',
|
|
260
|
+
'data-dpo-setting': 'fontFamily',
|
|
261
|
+
type: 'text',
|
|
262
|
+
list: 'dpo-font-options',
|
|
263
|
+
maxLength: 80,
|
|
264
|
+
autoComplete: 'off',
|
|
265
|
+
autoCapitalize: 'off',
|
|
266
|
+
spellCheck: false,
|
|
267
|
+
value: draft.fontFamily,
|
|
268
|
+
onChange: (event) => change('fontFamily', event.currentTarget.value),
|
|
269
|
+
'aria-describedby': 'dpo-settings-fontFamily-hint',
|
|
270
|
+
}),
|
|
271
|
+
h('datalist', { id: 'dpo-font-options', key: 'options' }, FONT_OPTIONS.map((option) => h('option', {
|
|
272
|
+
key: option.value,
|
|
273
|
+
value: option.value,
|
|
274
|
+
label: option.label,
|
|
275
|
+
}))),
|
|
276
|
+
])),
|
|
277
|
+
])
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return h('section', { className: 'dpo-settings', 'data-dpo-settings': '' }, [
|
|
281
|
+
h('h2', { key: 'title' }, '番茄钟'),
|
|
282
|
+
h('p', { className: 'dpo-description', key: 'description' },
|
|
283
|
+
'自动进行「专注 → 休息」循环。倒计时仅覆盖聊天消息区域;此处预览草稿,保存后应用。'),
|
|
284
|
+
formSnapshot.status === 'loading'
|
|
285
|
+
? h('p', { key: 'loading', role: 'status' }, '正在读取番茄钟设置…')
|
|
286
|
+
: null,
|
|
287
|
+
formSnapshot.status === 'unavailable'
|
|
288
|
+
? h('p', { key: 'unavailable', className: 'dpo-error', role: 'alert' },
|
|
289
|
+
'当前客户端无法读取番茄钟配置。')
|
|
290
|
+
: null,
|
|
291
|
+
ready ? h('form', { key: 'form', className: 'dpo-form', noValidate: true, onSubmit: save }, [
|
|
292
|
+
fontCard(),
|
|
293
|
+
phaseCard(FOCUS),
|
|
294
|
+
phaseCard('break'),
|
|
295
|
+
h('p', { className: 'dpo-hint', key: 'rule' },
|
|
296
|
+
'保存不打断当前计时。字体、颜色和不透明度立即生效;新时长从下一阶段开始生效。'),
|
|
297
|
+
h('div', { className: 'dpo-actions', key: 'actions' }, [
|
|
298
|
+
h('button', {
|
|
299
|
+
key: 'save',
|
|
300
|
+
type: 'submit',
|
|
301
|
+
className: 'dpo-button dpo-primary',
|
|
302
|
+
'data-dpo-save': '',
|
|
303
|
+
disabled: disabled || !dirty,
|
|
304
|
+
}, saving ? '保存中…' : '保存设置'),
|
|
305
|
+
h('button', {
|
|
306
|
+
key: 'discard',
|
|
307
|
+
type: 'button',
|
|
308
|
+
className: 'dpo-button',
|
|
309
|
+
'data-dpo-discard': '',
|
|
310
|
+
disabled: saving || !dirty,
|
|
311
|
+
onClick: () => loadLatest(),
|
|
312
|
+
}, '放弃修改'),
|
|
313
|
+
]),
|
|
314
|
+
]) : null,
|
|
315
|
+
!formSnapshot.writable && ready
|
|
316
|
+
? h('p', { key: 'readonly', className: 'dpo-warning' }, '当前配置为只读。')
|
|
317
|
+
: null,
|
|
318
|
+
error ? h('p', { key: 'error', className: 'dpo-error', role: 'alert' }, error) : null,
|
|
319
|
+
notice ? h('p', { key: 'notice', className: 'dpo-success', role: 'status' }, notice) : null,
|
|
320
|
+
!timerSnapshot.storageAvailable
|
|
321
|
+
? h('p', { key: 'storage', className: 'dpo-warning', role: 'status' },
|
|
322
|
+
'浏览器存储不可用:计时仍在运行,但刷新后可能重新开始。')
|
|
323
|
+
: null,
|
|
324
|
+
timerSnapshot.active ? h('div', { className: 'dpo-current', key: 'current' }, [
|
|
325
|
+
h('span', { key: 'phase' }, `当前阶段:${phaseName(timerSnapshot.phase)}${timerSnapshot.running ? '' : '(已暂停)'}`),
|
|
326
|
+
h('span', { key: 'time' }, `剩余:${timerSnapshot.text}`),
|
|
327
|
+
]) : null,
|
|
328
|
+
])
|
|
329
|
+
}
|