dsh-intertidal 0.0.0-stage → 0.1.4
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.en.md +103 -0
- package/README.md +102 -2
- package/assets/icon-180.png +0 -0
- package/assets/screenshots/media-modes.png +0 -0
- package/assets/screenshots/overlay-countdown.png +0 -0
- package/assets/screenshots/procedural-overview.png +0 -0
- package/assets/screenshots/settings-narrow.png +0 -0
- package/cordis.patch.yml +4 -0
- package/dist/client.js +3730 -0
- package/package.json +70 -4
- package/screenshots.json +6 -0
- package/src/cities.js +165 -0
- package/src/client/api.js +140 -0
- package/src/client/format.js +28 -0
- package/src/client/i18n.js +250 -0
- package/src/client/index.jsx +163 -0
- package/src/client/overlay.jsx +260 -0
- package/src/client/palette.js +376 -0
- package/src/client/rhythm.js +538 -0
- package/src/client/sea-canvas.jsx +1280 -0
- package/src/client/settings-panel.jsx +430 -0
- package/src/client/sound.js +163 -0
- package/src/client/styles.js +217 -0
- package/src/client/water.js +314 -0
- package/src/host.js +458 -0
- package/src/media.js +423 -0
- package/src/settings.js +322 -0
- package/src/sky.js +428 -0
- package/src/store.js +169 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// 潮间 —— 文案。zh-CN 与 en 各一份,键集合必须完全一致(test/i18n.test.js 会查)。
|
|
2
|
+
|
|
3
|
+
export const NS = 'intertidal'
|
|
4
|
+
|
|
5
|
+
export const zh = {
|
|
6
|
+
'panel.title': '潮间',
|
|
7
|
+
'panel.caption': '此刻',
|
|
8
|
+
'panel.remaining': '剩余',
|
|
9
|
+
'panel.paused': '已暂停',
|
|
10
|
+
'panel.crashed': '潮间出了点问题,点此关闭',
|
|
11
|
+
'action.open': '涨一次潮',
|
|
12
|
+
'action.close': '关闭',
|
|
13
|
+
'action.pip': '缩成小窗',
|
|
14
|
+
'action.expand': '展开整屏',
|
|
15
|
+
'action.pause': '暂停计时',
|
|
16
|
+
'action.resume': '继续计时',
|
|
17
|
+
'action.skip': '今天跳过',
|
|
18
|
+
'action.preview': '看一眼海',
|
|
19
|
+
'hint.keys': 'Esc 关闭 · C 缩窗 · Space 暂停',
|
|
20
|
+
'hint.offline': '暂时无法保存设置',
|
|
21
|
+
'sound.unlock': '点一下才能播放海浪声',
|
|
22
|
+
|
|
23
|
+
'settings.title': '潮间',
|
|
24
|
+
'settings.group.rhythm': '节奏',
|
|
25
|
+
'settings.group.form': '形态',
|
|
26
|
+
'settings.group.place': '地点',
|
|
27
|
+
'settings.group.sound': '声音',
|
|
28
|
+
'settings.group.access': '无障碍',
|
|
29
|
+
'settings.auto': '自动浮现',
|
|
30
|
+
'settings.auto.hint': '到点自己浮起来,到点自己退走',
|
|
31
|
+
'settings.interval': '间隔',
|
|
32
|
+
'settings.interval.hint': '两次浮现之间隔多久',
|
|
33
|
+
'settings.duration': '每次时长',
|
|
34
|
+
'settings.firstDelay': '首次投放延迟',
|
|
35
|
+
'settings.firstDelay.hint': '装完先安静一会儿,别一装就糊一脸',
|
|
36
|
+
'settings.quiet': '静默时段',
|
|
37
|
+
'settings.quiet.hint': '这段时间内不自动浮现(手动不受限)',
|
|
38
|
+
'settings.deferTyping': '输入中推迟',
|
|
39
|
+
'settings.deferTyping.hint': '正在打字时等你停手 20 秒;最多等 10 分钟则跳过本次',
|
|
40
|
+
'settings.manualExtends': '手动看过后顺延',
|
|
41
|
+
'settings.manualExtends.hint': '你主动看一眼,下一次定时就往后挪',
|
|
42
|
+
'settings.form': '浮现形式',
|
|
43
|
+
'settings.form.full': '整屏',
|
|
44
|
+
'settings.form.pip': '右下角小窗',
|
|
45
|
+
'settings.rememberForm': '记住上次形态',
|
|
46
|
+
'settings.caption': '角落小字',
|
|
47
|
+
'settings.caption.hint': '「此刻 · 上海 · 17:18」,不显示任何数据源',
|
|
48
|
+
'settings.city': '城市',
|
|
49
|
+
'settings.city.custom': '自定义经纬度',
|
|
50
|
+
'settings.lat': '纬度',
|
|
51
|
+
'settings.lon': '经度',
|
|
52
|
+
'settings.tz': '时区',
|
|
53
|
+
'settings.sound': '海浪声',
|
|
54
|
+
'settings.volume': '音量',
|
|
55
|
+
'settings.sound.hint': '程序化合成,不需要任何素材文件',
|
|
56
|
+
'settings.group.media': '画面素材',
|
|
57
|
+
'settings.media.mode': '素材来源',
|
|
58
|
+
'settings.media.off': '程序化海(默认)',
|
|
59
|
+
'settings.media.dir': '本地目录',
|
|
60
|
+
'settings.media.url': '网页 / 图片链接',
|
|
61
|
+
'settings.media.hint': '用真实照片或视频当海的底子;不选就还是程序化海。素材加载失败会自动退回程序化海。',
|
|
62
|
+
'settings.media.dir.hint': '从目录里随机取一张图片或视频,全程不联网',
|
|
63
|
+
'settings.media.dir.empty': '还没选目录',
|
|
64
|
+
'settings.media.pick': '选择目录',
|
|
65
|
+
'settings.media.scan': '检查目录',
|
|
66
|
+
'settings.media.scanOk': '目录里有素材',
|
|
67
|
+
'settings.media.scanEmpty': '目录里没有图片或视频',
|
|
68
|
+
'settings.media.scanFail': '读不到这个目录',
|
|
69
|
+
'settings.media.urls': '链接',
|
|
70
|
+
'settings.media.urls.hint': '一行一条,最多 8 条。直链图片/视频直接用;网页会尝试取 og:image 首图。',
|
|
71
|
+
'settings.media.fetch': '抓取并缓存',
|
|
72
|
+
'settings.media.fetchOk': '已缓存,下次浮现就会用',
|
|
73
|
+
'settings.media.fetchFail': '抓不到,检查链接或网络',
|
|
74
|
+
'settings.media.grade': '天光着色',
|
|
75
|
+
'settings.media.grade.hint': '按此刻的太阳给素材压暗/提亮、偏暖/偏冷;0 = 保持原图',
|
|
76
|
+
'settings.media.motion': '缓慢推拉',
|
|
77
|
+
'settings.media.motion.hint': 'Ken Burns,让静图不像壁纸;0 = 完全静止',
|
|
78
|
+
'settings.media.shuffle': '每次浮现换一张',
|
|
79
|
+
'settings.reducedMotion': '减少动态',
|
|
80
|
+
'settings.reducedMotion.auto': '跟随系统',
|
|
81
|
+
'settings.reducedMotion.on': '强制开',
|
|
82
|
+
'settings.reducedMotion.off': '强制关',
|
|
83
|
+
'settings.reducedMotion.hint': '开启后画面静止,且自动浮现默认关闭',
|
|
84
|
+
'settings.next': '下一次浮现',
|
|
85
|
+
'settings.next.soon': '马上就到',
|
|
86
|
+
'settings.next.off': '自动已关闭',
|
|
87
|
+
'settings.stateFile': '状态文件',
|
|
88
|
+
|
|
89
|
+
'unit.minutes': '分钟',
|
|
90
|
+
'unit.seconds': '秒',
|
|
91
|
+
'phase.dawn': '晨曦',
|
|
92
|
+
'phase.noon': '正午',
|
|
93
|
+
'phase.afternoon': '午后',
|
|
94
|
+
'phase.dusk': '黄昏',
|
|
95
|
+
'phase.night': '夜',
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const en = {
|
|
99
|
+
'panel.title': 'Intertidal',
|
|
100
|
+
'panel.caption': 'now',
|
|
101
|
+
'panel.remaining': 'remaining',
|
|
102
|
+
'panel.paused': 'paused',
|
|
103
|
+
'panel.crashed': 'Intertidal hit a problem — click to close',
|
|
104
|
+
'action.open': 'Raise the tide',
|
|
105
|
+
'action.close': 'Close',
|
|
106
|
+
'action.pip': 'Shrink to corner',
|
|
107
|
+
'action.expand': 'Expand',
|
|
108
|
+
'action.pause': 'Pause timer',
|
|
109
|
+
'action.resume': 'Resume timer',
|
|
110
|
+
'action.skip': 'Skip today',
|
|
111
|
+
'action.preview': 'Look at the sea',
|
|
112
|
+
'hint.keys': 'Esc close · C shrink · Space pause',
|
|
113
|
+
'hint.offline': 'Settings cannot be saved right now',
|
|
114
|
+
'sound.unlock': 'Click once to allow surf audio',
|
|
115
|
+
|
|
116
|
+
'settings.title': 'Intertidal',
|
|
117
|
+
'settings.group.rhythm': 'Rhythm',
|
|
118
|
+
'settings.group.form': 'Form',
|
|
119
|
+
'settings.group.place': 'Place',
|
|
120
|
+
'settings.group.sound': 'Sound',
|
|
121
|
+
'settings.group.access': 'Accessibility',
|
|
122
|
+
'settings.auto': 'Automatic',
|
|
123
|
+
'settings.auto.hint': 'It comes up on its own and leaves on its own',
|
|
124
|
+
'settings.interval': 'Interval',
|
|
125
|
+
'settings.interval.hint': 'How long between two appearances',
|
|
126
|
+
'settings.duration': 'Duration',
|
|
127
|
+
'settings.firstDelay': 'First appearance delay',
|
|
128
|
+
'settings.firstDelay.hint': 'Stay quiet right after install',
|
|
129
|
+
'settings.quiet': 'Quiet hours',
|
|
130
|
+
'settings.quiet.hint': 'No automatic appearance in this window (manual still works)',
|
|
131
|
+
'settings.deferTyping': 'Defer while typing',
|
|
132
|
+
'settings.deferTyping.hint': 'Waits 20s after you stop typing; gives up after 10 minutes',
|
|
133
|
+
'settings.manualExtends': 'Manual viewing pushes the next one',
|
|
134
|
+
'settings.manualExtends.hint': 'Looking on purpose moves the next timed appearance later',
|
|
135
|
+
'settings.form': 'Appearance',
|
|
136
|
+
'settings.form.full': 'Full screen',
|
|
137
|
+
'settings.form.pip': 'Corner window',
|
|
138
|
+
'settings.rememberForm': 'Remember last form',
|
|
139
|
+
'settings.caption': 'Corner caption',
|
|
140
|
+
'settings.caption.hint': '“Now · Shanghai · 17:18” — no data source involved',
|
|
141
|
+
'settings.city': 'City',
|
|
142
|
+
'settings.city.custom': 'Custom coordinates',
|
|
143
|
+
'settings.lat': 'Latitude',
|
|
144
|
+
'settings.lon': 'Longitude',
|
|
145
|
+
'settings.tz': 'Time zone',
|
|
146
|
+
'settings.sound': 'Surf sound',
|
|
147
|
+
'settings.volume': 'Volume',
|
|
148
|
+
'settings.sound.hint': 'Synthesised, no asset files',
|
|
149
|
+
'settings.group.media': 'Scenery',
|
|
150
|
+
'settings.media.mode': 'Source',
|
|
151
|
+
'settings.media.off': 'Procedural sea (default)',
|
|
152
|
+
'settings.media.dir': 'Local folder',
|
|
153
|
+
'settings.media.url': 'Web / image links',
|
|
154
|
+
'settings.media.hint': 'Use real photos or video as the sea bed; leave it off for the procedural sea. Falls back automatically if a file fails to load.',
|
|
155
|
+
'settings.media.dir.hint': 'Picks a random image or video from the folder. No network at all.',
|
|
156
|
+
'settings.media.dir.empty': 'No folder chosen',
|
|
157
|
+
'settings.media.pick': 'Choose folder',
|
|
158
|
+
'settings.media.scan': 'Check folder',
|
|
159
|
+
'settings.media.scanOk': 'Files found',
|
|
160
|
+
'settings.media.scanEmpty': 'No images or videos in that folder',
|
|
161
|
+
'settings.media.scanFail': 'Cannot read that folder',
|
|
162
|
+
'settings.media.urls': 'Links',
|
|
163
|
+
'settings.media.urls.hint': 'One per line, up to 8. Direct image/video URLs are used as-is; pages are scraped for og:image.',
|
|
164
|
+
'settings.media.fetch': 'Fetch and cache',
|
|
165
|
+
'settings.media.fetchOk': 'Cached — the next appearance will use it',
|
|
166
|
+
'settings.media.fetchFail': 'Could not fetch; check the link or network',
|
|
167
|
+
'settings.media.grade': 'Time-of-day grade',
|
|
168
|
+
'settings.media.grade.hint': 'Darkens/brightens and warms/cools the photo to match the sun right now; 0 keeps the original',
|
|
169
|
+
'settings.media.motion': 'Slow push',
|
|
170
|
+
'settings.media.motion.hint': 'Ken Burns, so a still photo does not look like wallpaper; 0 is perfectly still',
|
|
171
|
+
'settings.media.shuffle': 'New one every appearance',
|
|
172
|
+
'settings.reducedMotion': 'Reduced motion',
|
|
173
|
+
'settings.reducedMotion.auto': 'Follow system',
|
|
174
|
+
'settings.reducedMotion.on': 'Force on',
|
|
175
|
+
'settings.reducedMotion.off': 'Force off',
|
|
176
|
+
'settings.reducedMotion.hint': 'Static frame, and automatic appearance defaults to off',
|
|
177
|
+
'settings.next': 'Next appearance',
|
|
178
|
+
'settings.next.soon': 'any moment',
|
|
179
|
+
'settings.next.off': 'automatic is off',
|
|
180
|
+
'settings.stateFile': 'State file',
|
|
181
|
+
|
|
182
|
+
'unit.minutes': 'min',
|
|
183
|
+
'unit.seconds': 's',
|
|
184
|
+
'phase.dawn': 'dawn',
|
|
185
|
+
'phase.noon': 'noon',
|
|
186
|
+
'phase.afternoon': 'afternoon',
|
|
187
|
+
'phase.dusk': 'dusk',
|
|
188
|
+
'phase.night': 'night',
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** 当前语言:优先 document.documentElement.lang / navigator.language。 */
|
|
192
|
+
export function detectLang(doc = globalThis.document, nav = globalThis.navigator) {
|
|
193
|
+
const fromDoc = doc?.documentElement?.lang
|
|
194
|
+
const raw = String(fromDoc || nav?.language || 'zh')
|
|
195
|
+
return raw.toLowerCase().startsWith('zh') ? 'zh' : 'en'
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** 造一个 t:找不到键就退中文,再退键名本身。 */
|
|
199
|
+
export function makeT(dict, fallback = zh) {
|
|
200
|
+
return (key) => dict[key] ?? fallback[key] ?? key
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** 'zh-CN' / 'zh' / 'zh-Hant' → 'zh';其余 → 'en'。 */
|
|
204
|
+
export function normalizeLang(value) {
|
|
205
|
+
return String(value ?? '').toLowerCase().startsWith('zh') ? 'zh' : 'en'
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* 造一个**跟随 DSH 当前语言**的 t。
|
|
210
|
+
*
|
|
211
|
+
* 为什么不用 `locale.bind(NS)`:`apply` 执行时 `ctx.get('locale')` 可能还没就绪
|
|
212
|
+
* (cordis 服务是后来才 provide 的,`get` 拿到 undefined 后不会重试),这时 bind 根本不存在,
|
|
213
|
+
* 只能走兜底语言——那正是"DSH 是中文、插件却是英文"的现场。
|
|
214
|
+
*
|
|
215
|
+
* 这里直接读 `locale.getSnapshot().active`(temp-chat 也用它),拿不到才退
|
|
216
|
+
* documentElement.lang / navigator.language。每次翻译时现算,所以语言切换即时生效。
|
|
217
|
+
*/
|
|
218
|
+
export function createTranslator(locale, doc = globalThis.document, nav = globalThis.navigator) {
|
|
219
|
+
const active = () => {
|
|
220
|
+
try {
|
|
221
|
+
const snapshot = locale?.getSnapshot?.()
|
|
222
|
+
if (snapshot && snapshot.active) return normalizeLang(snapshot.active)
|
|
223
|
+
} catch {
|
|
224
|
+
/* 服务还没起来 / API 变了 */
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
const attr = doc?.documentElement?.getAttribute?.('lang')
|
|
228
|
+
if (attr) return normalizeLang(attr)
|
|
229
|
+
} catch {
|
|
230
|
+
/* ignore */
|
|
231
|
+
}
|
|
232
|
+
return normalizeLang(nav?.language ?? 'zh')
|
|
233
|
+
}
|
|
234
|
+
const t = (key) => {
|
|
235
|
+
const dict = active() === 'en' ? en : zh
|
|
236
|
+
return dict[key] ?? zh[key] ?? key
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
t,
|
|
240
|
+
getLang: active,
|
|
241
|
+
/** 语言变化时通知组件重渲染(拿得到 subscribe 才有)。 */
|
|
242
|
+
subscribe(callback) {
|
|
243
|
+
try {
|
|
244
|
+
return locale?.subscribe?.(() => callback(active())) ?? (() => {})
|
|
245
|
+
} catch {
|
|
246
|
+
return () => {}
|
|
247
|
+
}
|
|
248
|
+
},
|
|
249
|
+
}
|
|
250
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// 潮间 —— 浏览器半片入口。
|
|
2
|
+
//
|
|
3
|
+
// 本文件只做"接线":造 runtime、注入样式、注册四个槽位。任何槽位注册失败都只打告警,
|
|
4
|
+
// 绝不让插件把 DSH 的 web boot 拖垮(spec §11 R5)。
|
|
5
|
+
//
|
|
6
|
+
// 槽位用哪几个、为什么:
|
|
7
|
+
// shell.overlay 整窗覆盖层(不抢面板、不改变 content 区),是"到点浮现"的唯一载体;
|
|
8
|
+
// settings.section 设置页(含「看一眼海」「今天跳过」,是主要的手动入口)。
|
|
9
|
+
//
|
|
10
|
+
// v2 起**不再注册** `sidebar.panellist` 与 `main` 面板:用户反馈侧栏那一行"看起来没有作用"
|
|
11
|
+
// (它只是再开一次海,而海本来就会自己来)。手动入口保留在设置页,加上可自配的快捷键。
|
|
12
|
+
|
|
13
|
+
import Overlay from './overlay.jsx'
|
|
14
|
+
import SettingsPanel from './settings-panel.jsx'
|
|
15
|
+
import { createApi } from './api.js'
|
|
16
|
+
import { createRuntime } from './rhythm.js'
|
|
17
|
+
import { createSurfSound } from './sound.js'
|
|
18
|
+
import { injectStyles } from './styles.js'
|
|
19
|
+
import { NS, createTranslator, en, zh } from './i18n.js'
|
|
20
|
+
|
|
21
|
+
const logger = {
|
|
22
|
+
warn: (...args) => console.warn('[dsh-intertidal]', ...args),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const PANEL_ID = 'intertidal'
|
|
26
|
+
|
|
27
|
+
const plugin = {
|
|
28
|
+
name: 'dsh-intertidal',
|
|
29
|
+
// `slots` 与 `locale` 都是 DSH 的常驻服务,一并声明:这样 apply 里拿到的 locale 一定是
|
|
30
|
+
// 已就绪的那个(早先只 inject slots、用 ctx.get('locale') 现取,曾经拿到 undefined,
|
|
31
|
+
// 于是插件语言跟着 documentElement.lang 跑 —— 表现就是"DSH 中文、插件英文")。
|
|
32
|
+
inject: ['slots', 'locale'],
|
|
33
|
+
apply(ctx) {
|
|
34
|
+
const locale = ctx.locale ?? ctx.get?.('locale') ?? null
|
|
35
|
+
const { t, getLang, subscribe: subscribeLang } = createTranslator(locale)
|
|
36
|
+
|
|
37
|
+
if (locale && typeof locale.register === 'function') {
|
|
38
|
+
ctx.effect(() => {
|
|
39
|
+
try {
|
|
40
|
+
return locale.register(NS, { zh, en })
|
|
41
|
+
} catch (error) {
|
|
42
|
+
logger.warn('注册文案失败(可能已被注册过):', error)
|
|
43
|
+
return () => {}
|
|
44
|
+
}
|
|
45
|
+
}, 'dsh-intertidal: dictionaries')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
ctx.effect(() => {
|
|
49
|
+
const remove = injectStyles()
|
|
50
|
+
return () => {
|
|
51
|
+
remove()
|
|
52
|
+
for (const node of globalThis.document?.querySelectorAll?.('style[data-plugin-css="dsh-intertidal"]') ?? []) {
|
|
53
|
+
node.remove()
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}, 'dsh-intertidal: styles')
|
|
57
|
+
|
|
58
|
+
// ---- runtime:一次创建,整个插件生命周期共用 -----------------------------
|
|
59
|
+
const api = createApi({
|
|
60
|
+
onStatus: (offline) => {
|
|
61
|
+
if (offline) logger.warn('宿主接口不可用,退镜像设置运行')
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
const runtime = createRuntime({ api })
|
|
65
|
+
ctx.effect(() => {
|
|
66
|
+
const dropSound = runtime.attachSound(createSurfSound())
|
|
67
|
+
void runtime.init()
|
|
68
|
+
return () => {
|
|
69
|
+
dropSound()
|
|
70
|
+
runtime.dispose()
|
|
71
|
+
}
|
|
72
|
+
}, 'dsh-intertidal: runtime')
|
|
73
|
+
|
|
74
|
+
// ---- 槽位 ---------------------------------------------------------------
|
|
75
|
+
const withRuntime = () => ({
|
|
76
|
+
runtime,
|
|
77
|
+
ctx,
|
|
78
|
+
t,
|
|
79
|
+
getLang,
|
|
80
|
+
subscribeLang,
|
|
81
|
+
// 可选:DSH 的工作区选择器(`uiWorkspace.pickDirectory`),拿不到就让手输路径
|
|
82
|
+
pickDirectory: () => {
|
|
83
|
+
try {
|
|
84
|
+
return ctx.get?.('uiWorkspace')?.pickDirectory?.()
|
|
85
|
+
} catch {
|
|
86
|
+
return null
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
ctx.slots.inject('shell.overlay', () =>
|
|
93
|
+
ctx.slots.register(
|
|
94
|
+
{
|
|
95
|
+
name: 'shell.overlay',
|
|
96
|
+
id: 'intertidal.sea',
|
|
97
|
+
order: 500,
|
|
98
|
+
label: () => t('panel.title'),
|
|
99
|
+
locale: NS,
|
|
100
|
+
inject: withRuntime,
|
|
101
|
+
},
|
|
102
|
+
Overlay,
|
|
103
|
+
),
|
|
104
|
+
)
|
|
105
|
+
} catch (error) {
|
|
106
|
+
logger.warn('注册整窗覆盖层失败:', error)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
ctx.slots.inject('settings.section', () =>
|
|
113
|
+
ctx.slots.register(
|
|
114
|
+
{
|
|
115
|
+
name: 'settings.section',
|
|
116
|
+
id: PANEL_ID,
|
|
117
|
+
order: 88,
|
|
118
|
+
label: () => t('settings.title'),
|
|
119
|
+
locale: NS,
|
|
120
|
+
inject: withRuntime,
|
|
121
|
+
},
|
|
122
|
+
SettingsPanel,
|
|
123
|
+
),
|
|
124
|
+
)
|
|
125
|
+
} catch (error) {
|
|
126
|
+
logger.warn('注册设置页失败:', error)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---- 快捷键(可选服务)---------------------------------------------------
|
|
130
|
+
// 侧栏入口已移除,手动开海除了设置页按钮,还留一个**不带默认绑定**的命令:
|
|
131
|
+
// 它出现在 设置 → 快捷键 里,用户自己绑定(不给默认值是为了不跟已装插件抢键位)。
|
|
132
|
+
try {
|
|
133
|
+
ctx.inject(['shortcuts'], (scope) => {
|
|
134
|
+
scope.effect(() => {
|
|
135
|
+
try {
|
|
136
|
+
const dispose = scope.shortcuts.register({
|
|
137
|
+
id: 'intertidal.open',
|
|
138
|
+
label: () => t('action.open'),
|
|
139
|
+
aliases: ['涨一次潮', 'look at the sea', 'intertidal'],
|
|
140
|
+
defaults: {},
|
|
141
|
+
regions: ['page'],
|
|
142
|
+
modals: [],
|
|
143
|
+
resolve: () => ({
|
|
144
|
+
status: 'handled',
|
|
145
|
+
run: () => void runtime.show('manual'),
|
|
146
|
+
}),
|
|
147
|
+
})
|
|
148
|
+
return () => dispose?.()
|
|
149
|
+
} catch (error) {
|
|
150
|
+
logger.warn('注册快捷键命令失败(不影响其他功能):', error)
|
|
151
|
+
return () => {}
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
} catch (error) {
|
|
156
|
+
logger.warn('shortcuts 服务不可用:', error)
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export const inject = plugin.inject
|
|
162
|
+
export const apply = plugin.apply
|
|
163
|
+
export const name = plugin.name
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// 潮间 —— 覆盖层:形态状态机(full / pip / hidden)的 UI 与键盘处理。
|
|
2
|
+
//
|
|
3
|
+
// 三条硬规则,来自 spec §5:
|
|
4
|
+
// 1. 浮现后**不**因为你打字就自己躲开——只有到点、Esc/关闭、或你按 C 才改变形态;
|
|
5
|
+
// 2. 全窗状态下只接管 Esc / C / Space 三个键,其余一律放行;
|
|
6
|
+
// 3. 整屏与小窗共享同一次浮现的剩余时长,缩窗不重置计时。
|
|
7
|
+
//
|
|
8
|
+
// 这一层不自己计时,也不自己算颜色:计时在 rhythm.js,颜色在 palette.js。这里只做渲染
|
|
9
|
+
// 和交互,出任何错都由 Guarded 兜住,退成一行"潮间出了点问题,点此关闭",绝不拖垮 DSH。
|
|
10
|
+
|
|
11
|
+
import { Component, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
12
|
+
import SeaCanvas from './sea-canvas.jsx'
|
|
13
|
+
import { findCity } from '../cities.js'
|
|
14
|
+
import { formatClock, formatRemaining } from './format.js'
|
|
15
|
+
|
|
16
|
+
/** 覆盖层内部的错误边界:DSH 主流程不能因为一片海而受损。 */
|
|
17
|
+
class Guarded extends Component {
|
|
18
|
+
constructor(props) {
|
|
19
|
+
super(props)
|
|
20
|
+
this.state = { failed: false }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
static getDerivedStateFromError() {
|
|
24
|
+
return { failed: true }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
componentDidCatch(error) {
|
|
28
|
+
console.warn('[dsh-intertidal] 覆盖层渲染失败:', error)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
render() {
|
|
32
|
+
if (!this.state.failed) return this.props.children
|
|
33
|
+
return (
|
|
34
|
+
<div className="it-error">
|
|
35
|
+
<span>{this.props.label}</span>
|
|
36
|
+
<button type="button" className="it-btn" onClick={() => this.setState({ failed: false })}>
|
|
37
|
+
{this.props.closeLabel}
|
|
38
|
+
</button>
|
|
39
|
+
</div>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 输入框/可编辑区里按空格是打字,不是暂停。 */
|
|
45
|
+
function isEditableTarget(target) {
|
|
46
|
+
if (!target || typeof target !== 'object') return false
|
|
47
|
+
const tag = String(target.tagName ?? '').toLowerCase()
|
|
48
|
+
if (tag === 'input' || tag === 'textarea' || tag === 'select') return true
|
|
49
|
+
return target.isContentEditable === true
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
function placeName(location, lang) {
|
|
55
|
+
if (!location) return ''
|
|
56
|
+
const city = findCity?.(location.city)
|
|
57
|
+
if (city) return lang === 'en' ? city.en : city.zh
|
|
58
|
+
return location.city || ''
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function useRuntime(runtime) {
|
|
62
|
+
const [snapshot, setSnapshot] = useState(() => runtime.getState())
|
|
63
|
+
useEffect(() => runtime.subscribe(setSnapshot), [runtime])
|
|
64
|
+
return snapshot
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 小窗拖拽 + 四角吸附。只在 pip 形态下绑定。 */
|
|
68
|
+
function usePipDrag(runtime, form, enabled) {
|
|
69
|
+
const ref = useRef(null)
|
|
70
|
+
const [dragging, setDragging] = useState(false)
|
|
71
|
+
const drag = useRef(null)
|
|
72
|
+
|
|
73
|
+
const onPointerDown = useCallback((event) => {
|
|
74
|
+
if (!enabled || form !== 'pip') return
|
|
75
|
+
if (event.target?.closest?.('.it-btn')) return
|
|
76
|
+
const node = ref.current
|
|
77
|
+
if (!node) return
|
|
78
|
+
const rect = node.getBoundingClientRect()
|
|
79
|
+
drag.current = { dx: event.clientX - rect.left, dy: event.clientY - rect.top, w: rect.width, h: rect.height }
|
|
80
|
+
node.setPointerCapture?.(event.pointerId)
|
|
81
|
+
setDragging(true)
|
|
82
|
+
}, [enabled, form])
|
|
83
|
+
|
|
84
|
+
const onPointerMove = useCallback((event) => {
|
|
85
|
+
const info = drag.current
|
|
86
|
+
const node = ref.current
|
|
87
|
+
if (!info || !node) return
|
|
88
|
+
const left = Math.min(Math.max(0, event.clientX - info.dx), window.innerWidth - info.w)
|
|
89
|
+
const top = Math.min(Math.max(0, event.clientY - info.dy), window.innerHeight - info.h)
|
|
90
|
+
node.style.left = `${left}px`
|
|
91
|
+
node.style.top = `${top}px`
|
|
92
|
+
node.style.right = 'auto'
|
|
93
|
+
node.style.bottom = 'auto'
|
|
94
|
+
}, [])
|
|
95
|
+
|
|
96
|
+
const onPointerUp = useCallback((event) => {
|
|
97
|
+
const node = ref.current
|
|
98
|
+
if (!drag.current || !node) return
|
|
99
|
+
drag.current = null
|
|
100
|
+
setDragging(false)
|
|
101
|
+
node.releasePointerCapture?.(event.pointerId)
|
|
102
|
+
// 吸附到最近的四角
|
|
103
|
+
const rect = node.getBoundingClientRect()
|
|
104
|
+
const gap = 24
|
|
105
|
+
const centerX = rect.left + rect.width / 2
|
|
106
|
+
const centerY = rect.top + rect.height / 2
|
|
107
|
+
const left = centerX < window.innerWidth / 2 ? gap : window.innerWidth - rect.width - gap
|
|
108
|
+
const top = centerY < window.innerHeight / 2 ? gap : window.innerHeight - rect.height - gap
|
|
109
|
+
node.style.left = `${Math.max(gap, left)}px`
|
|
110
|
+
node.style.top = `${Math.max(gap, top)}px`
|
|
111
|
+
node.style.right = 'auto'
|
|
112
|
+
node.style.bottom = 'auto'
|
|
113
|
+
}, [])
|
|
114
|
+
|
|
115
|
+
return { ref, dragging, onPointerDown, onPointerMove, onPointerUp }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export default function Overlay({ runtime, t = (k) => k, getLang = () => 'zh', subscribeLang }) {
|
|
119
|
+
const snapshot = useRuntime(runtime)
|
|
120
|
+
const lang = typeof getLang === 'function' ? getLang() : 'zh'
|
|
121
|
+
// 语言切换时重渲染(locale.subscribe 拿不到就只是不跟随,不报错)
|
|
122
|
+
const [, bumpLang] = useState(0)
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
if (typeof subscribeLang !== 'function') return undefined
|
|
125
|
+
const unsub = subscribeLang(() => bumpLang((value) => value + 1))
|
|
126
|
+
return () => unsub?.()
|
|
127
|
+
}, [subscribeLang])
|
|
128
|
+
const { form, exiting, settings, sky, paused } = snapshot
|
|
129
|
+
const visible = form !== 'hidden'
|
|
130
|
+
const drag = usePipDrag(runtime, form, visible)
|
|
131
|
+
|
|
132
|
+
// 键盘:只在覆盖层可见时接管 Esc / C / Space,别的一概放行。
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
if (!visible) return undefined
|
|
135
|
+
const onKeyDown = (event) => {
|
|
136
|
+
if (event.defaultPrevented) return
|
|
137
|
+
const key = event.key
|
|
138
|
+
const editable = isEditableTarget(event.target)
|
|
139
|
+
if (key === 'Escape') {
|
|
140
|
+
event.preventDefault()
|
|
141
|
+
runtime.hide('manual')
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
if (editable) return
|
|
145
|
+
if (key === 'c' || key === 'C') {
|
|
146
|
+
event.preventDefault()
|
|
147
|
+
runtime.toggleForm()
|
|
148
|
+
} else if (key === ' ' || key === 'Spacebar') {
|
|
149
|
+
event.preventDefault()
|
|
150
|
+
runtime.togglePause()
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
window.addEventListener('keydown', onKeyDown, true)
|
|
154
|
+
return () => window.removeEventListener('keydown', onKeyDown, true)
|
|
155
|
+
}, [runtime, visible])
|
|
156
|
+
|
|
157
|
+
// 从 pip 拖拽回整屏时,必须清掉拖拽写进内联样式的 left/top/right/bottom,
|
|
158
|
+
// 否则 `inset: 0` 会被内联值盖住,海会停在小窗的位置上。
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
const node = drag.ref.current
|
|
161
|
+
if (!node || form !== 'full' || drag.dragging) return
|
|
162
|
+
node.style.left = ''
|
|
163
|
+
node.style.top = ''
|
|
164
|
+
node.style.right = ''
|
|
165
|
+
node.style.bottom = ''
|
|
166
|
+
}, [form, drag.dragging, drag.ref])
|
|
167
|
+
|
|
168
|
+
// 覆盖层出现时不要让焦点飘走:不主动 focus,输入框的光标与滚动位置必须原地不动。
|
|
169
|
+
const caption = useMemo(() => {
|
|
170
|
+
if (!settings?.caption) return ''
|
|
171
|
+
const when = formatClock(new Date(), settings?.location?.tz)
|
|
172
|
+
const where = placeName(settings?.location, lang)
|
|
173
|
+
const phase = t(`phase.${sky?.phase ?? 'afternoon'}`)
|
|
174
|
+
return [t('panel.caption'), where, `${when} · ${phase}`].filter(Boolean).join(' · ')
|
|
175
|
+
}, [settings?.caption, settings?.location?.city, settings?.location?.tz, sky?.phase, lang, t])
|
|
176
|
+
|
|
177
|
+
if (!visible) return null
|
|
178
|
+
|
|
179
|
+
const exitingAttr = exiting ? (exiting.kind === 'pip' || form === 'pip' ? 'pip' : '1') : '0'
|
|
180
|
+
const dialogLabel = exiting ? 'closing' : 'open'
|
|
181
|
+
|
|
182
|
+
// role 用 region 而不是 dialog:这是氛围层不是模态框,而且 rhythm 里"有对话框就推迟"
|
|
183
|
+
// 的逻辑会把自己的浮层误判成阻断源。
|
|
184
|
+
return (
|
|
185
|
+
<Guarded label={t('panel.crashed')} closeLabel={t('action.close')}>
|
|
186
|
+
<div
|
|
187
|
+
ref={drag.ref}
|
|
188
|
+
className="it-root"
|
|
189
|
+
data-form={form}
|
|
190
|
+
data-exit={exitingAttr}
|
|
191
|
+
data-dragging={drag.dragging ? '1' : '0'}
|
|
192
|
+
role="region"
|
|
193
|
+
aria-label={t('panel.title')}
|
|
194
|
+
data-state={dialogLabel}
|
|
195
|
+
onPointerDown={drag.onPointerDown}
|
|
196
|
+
onPointerMove={drag.onPointerMove}
|
|
197
|
+
onPointerUp={drag.onPointerUp}
|
|
198
|
+
onPointerCancel={drag.onPointerUp}
|
|
199
|
+
>
|
|
200
|
+
<div className="it-canvas-holder">
|
|
201
|
+
<SeaCanvas
|
|
202
|
+
now={sky}
|
|
203
|
+
media={snapshot.media}
|
|
204
|
+
form={form}
|
|
205
|
+
paused={paused}
|
|
206
|
+
reducedMotion={runtime.reducedMotion}
|
|
207
|
+
onStats={(stats) => runtime.reportStats(stats)}
|
|
208
|
+
/>
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
{caption ? <div className="it-caption">{caption}</div> : null}
|
|
212
|
+
{snapshot.note ? <div className="it-toast">{t(snapshot.note)}</div> : null}
|
|
213
|
+
|
|
214
|
+
<div className="it-controls">
|
|
215
|
+
{/* 倒计时:一直在,不跟着按钮一起淡入淡出 —— 它是"还有多久退干净"的唯一提示 */}
|
|
216
|
+
<span
|
|
217
|
+
className="it-countdown"
|
|
218
|
+
data-paused={paused ? '1' : '0'}
|
|
219
|
+
title={paused ? t('panel.paused') : t('panel.remaining')}
|
|
220
|
+
aria-label={paused ? t('panel.paused') : t('panel.remaining')}
|
|
221
|
+
>
|
|
222
|
+
{formatRemaining(snapshot.remainingMs)}
|
|
223
|
+
</span>
|
|
224
|
+
<span className="it-btnbar">
|
|
225
|
+
<button
|
|
226
|
+
type="button"
|
|
227
|
+
className="it-btn"
|
|
228
|
+
data-icon="1"
|
|
229
|
+
title={paused ? t('action.resume') : t('action.pause')}
|
|
230
|
+
aria-label={paused ? t('action.resume') : t('action.pause')}
|
|
231
|
+
onClick={() => runtime.togglePause()}
|
|
232
|
+
>
|
|
233
|
+
{paused ? '▶' : '❚❚'}
|
|
234
|
+
</button>
|
|
235
|
+
<button
|
|
236
|
+
type="button"
|
|
237
|
+
className="it-btn"
|
|
238
|
+
data-icon="1"
|
|
239
|
+
title={form === 'full' ? t('action.pip') : t('action.expand')}
|
|
240
|
+
aria-label={form === 'full' ? t('action.pip') : t('action.expand')}
|
|
241
|
+
onClick={() => runtime.toggleForm()}
|
|
242
|
+
>
|
|
243
|
+
{form === 'full' ? '⤡' : '⤢'}
|
|
244
|
+
</button>
|
|
245
|
+
<button
|
|
246
|
+
type="button"
|
|
247
|
+
className="it-btn"
|
|
248
|
+
data-icon="1"
|
|
249
|
+
title={t('action.close')}
|
|
250
|
+
aria-label={t('action.close')}
|
|
251
|
+
onClick={() => runtime.hide('manual')}
|
|
252
|
+
>
|
|
253
|
+
✕
|
|
254
|
+
</button>
|
|
255
|
+
</span>
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
</Guarded>
|
|
259
|
+
)
|
|
260
|
+
}
|