hexo-theme-stellar 2.0.0-rc.5 → 2.0.0

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.
Files changed (47) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +8 -0
  3. package/README_EN.md +8 -0
  4. package/_config.yml +12 -1
  5. package/languages/en.yml +0 -2
  6. package/languages/zh-CN.yml +0 -2
  7. package/languages/zh-TW.yml +0 -2
  8. package/layout/_partial/cover/wiki_cover.ejs +9 -3
  9. package/layout/_partial/main/navbar/article_banner.ejs +9 -0
  10. package/layout/_partial/main/notebook/notebook_card.ejs +33 -13
  11. package/layout/_partial/primitives/navigation.ejs +3 -2
  12. package/layout/_partial/regions/widgets.ejs +1 -1
  13. package/layout/_partial/sidebar/menu.ejs +3 -0
  14. package/layout/notebooks.ejs +8 -6
  15. package/layout/settings.ejs +2 -4
  16. package/legal/THIRD-PARTY-NOTICES.md +2 -2
  17. package/package.json +1 -1
  18. package/scripts/events/lib/notebooks.js +5 -1
  19. package/scripts/lib/client-assets.js +1 -0
  20. package/scripts/lib/config-schema.js +2 -2
  21. package/scripts/lib/contribution-registry.js +14 -0
  22. package/scripts/lib/hero-effect-registry.js +50 -1
  23. package/scripts/lib/image-metadata.js +19 -4
  24. package/scripts/lib/internal-constants.js +1 -0
  25. package/scripts/lib/models/wiki.js +2 -0
  26. package/scripts/lib/regions.js +8 -1
  27. package/scripts/lib/source-config.js +6 -1
  28. package/scripts/schema/config-rules.js +3 -0
  29. package/scripts/schema/content-config-rules.js +4 -0
  30. package/scripts/schema/content-config-schema.js +2 -0
  31. package/scripts/schema/model-schema.js +4 -0
  32. package/source/css/_common/base.styl +1 -0
  33. package/source/css/_common/input.styl +12 -15
  34. package/source/css/_common/loading.styl +17 -0
  35. package/source/css/_components/pages/settings.styl +0 -1
  36. package/source/css/_components/partial/cover.styl +20 -0
  37. package/source/css/_components/sidebar/nav-area.styl +10 -0
  38. package/source/css/_components/sidebar/search.styl +65 -1
  39. package/source/css/_components/widgets/toc.styl +1 -1
  40. package/source/css/_components/wiki-card.styl +3 -1
  41. package/source/js/main.js +51 -4
  42. package/source/js/profile.js +1 -1
  43. package/source/js/runtime/extensions/hero-parallax.js +70 -0
  44. package/source/js/runtime/extensions/partial-navigation.js +18 -1
  45. package/source/js/runtime/extensions/settings.js +2 -3
  46. package/source/js/runtime/hero-effects/strands.js +302 -0
  47. package/source/js/runtime/image-color.js +4 -1
@@ -61,7 +61,10 @@ function resolveRegion(layers, region, options = {}) {
61
61
  result.brand = false;
62
62
  result.menu = [];
63
63
  }
64
- if (region === "leftbar") result.footer = { actions: [] };
64
+ if (region === "leftbar") {
65
+ result.menuColumns = 1;
66
+ result.footer = { actions: [] };
67
+ }
65
68
  for (const layer of layers) {
66
69
  const current = regionLayer(layer?.[region]);
67
70
  if (!current) continue;
@@ -72,6 +75,9 @@ function resolveRegion(layers, region, options = {}) {
72
75
  if (["topbar", "leftbar"].includes(region) && Array.isArray(current.menu)) {
73
76
  result.menu = current.menu.map(clone);
74
77
  }
78
+ if (region === "leftbar" && current.menuColumns != null) {
79
+ result.menuColumns = current.menuColumns;
80
+ }
75
81
  if (region === "leftbar" && Array.isArray(current.footer?.actions)) {
76
82
  result.footer.actions = current.footer.actions.map(clone);
77
83
  }
@@ -112,6 +118,7 @@ function resolveRegions(options = {}) {
112
118
  menu: clone(state.menu)
113
119
  } : {}),
114
120
  ...(region === "leftbar" ? {
121
+ menuColumns: state.menuColumns,
115
122
  defaultState: options.defaultState || "expanded",
116
123
  footer: clone(state.footer)
117
124
  } : {})
@@ -7,6 +7,11 @@ const snapshots = new WeakMap();
7
7
 
8
8
  function sourcePathForData(key) { return `source/_data/${key}.yml`; }
9
9
  function sourcePathForPage(page) { return page.source ? `source/${page.source}` : (page.path || "<page>"); }
10
+ // hexo-front-matter 只识别 LF;Hexo 宿主通过 hexo-fs 读取源码时会去除 BOM 并规范化 CRLF,
11
+ // 直接读取源码必须保持一致,否则 Windows 上的 CRLF 内容会丢失全部 Front Matter。
12
+ function readSourceText(file) {
13
+ return fs.readFileSync(file, "utf8").replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
14
+ }
10
15
  function sourceCache(ctx) {
11
16
  if (!snapshots.has(ctx)) snapshots.set(ctx, new Map());
12
17
  return snapshots.get(ctx);
@@ -29,7 +34,7 @@ function readFrontMatter(ctx, page) {
29
34
  const stamp = [stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs].join(":");
30
35
  const previous = cache.get(page.source);
31
36
  if (previous?.stamp === stamp) return previous.config;
32
- const config = frontMatter.parse(fs.readFileSync(file, "utf8"));
37
+ const config = frontMatter.parse(readSourceText(file));
33
38
  delete config._content;
34
39
  cache.set(page.source, { stamp, config });
35
40
  return config;
@@ -156,6 +156,7 @@ const CONFIG_RULES = Object.freeze([
156
156
  ["leftbar.default_state", { values: ["expanded", "collapsed"] }],
157
157
  ["leftbar.enabled", { type: ["boolean"] }],
158
158
  ["leftbar.brand", regionBrand(leftbarBrandProperties)],
159
+ ["leftbar.menu_columns", { minimum: 1, maximum: 5, validator: "non_negative_integer" }],
159
160
  ["leftbar.menu", { validator: "menu_items", items: menuItem }],
160
161
  ["leftbar.footer.actions", { validator: "footer_actions", items: footerAction }],
161
162
  ["leftbar.widgets", { items: widgetItems }],
@@ -169,6 +170,7 @@ const CONFIG_RULES = Object.freeze([
169
170
  ["profiles.*.topbar.widgets", { type: ["array", "null"], defaultValue: null, items: widgetItems }],
170
171
  ["profiles.*.leftbar.enabled", { type: ["boolean", "null"], defaultValue: null }],
171
172
  ["profiles.*.leftbar.brand", inheritedRegionBrand(leftbarBrandProperties)],
173
+ ["profiles.*.leftbar.menu_columns", { type: ["number", "null"], defaultValue: null, minimum: 1, maximum: 5, validator: "nullable_non_negative_integer" }],
172
174
  ["profiles.*.leftbar.menu", { type: ["array", "null"], defaultValue: null, validator: "menu_items", items: menuItem }],
173
175
  ["profiles.*.leftbar.footer.actions", { type: ["array", "null"], defaultValue: null, validator: "footer_actions", items: footerAction }],
174
176
  ["profiles.*.leftbar.widgets", { type: ["array", "null"], defaultValue: null, items: widgetItems }],
@@ -274,6 +276,7 @@ const CONFIG_RULES = Object.freeze([
274
276
  ["services.contributors.provider", { values: ["github"] }],
275
277
  ["services.contributors.github.repositories", { validator: "contributor_repositories", items: contributorRepository }],
276
278
  ["services.github.*", { validator: "absolute_http_url" }],
279
+ ["services.gravatar.base_url", { validator: "absolute_http_url" }],
277
280
  ["services.github_card.provider", { values: ["github_readme_stats"] }],
278
281
  ["services.github_card.github_readme_stats", { runtimeKey: "github_readme_stats" }],
279
282
  ["services.github_card.github_readme_stats.endpoint", { type: ["string"], validator: "absolute_http_url" }],
@@ -127,6 +127,7 @@ const CONTENT_OVERRIDE_DEFINITIONS = [
127
127
  ["leftbar.brand.ghuser", ["string", "null"], literal(null)],
128
128
  ["leftbar.brand.search", ["boolean", "null"], literal(null)],
129
129
  ["leftbar.brand.href", ["string", "null"], literal(null)],
130
+ ["leftbar.menu_columns", ["number", "null"], literal(null), { minimum: 1, maximum: 5 }],
130
131
  ["leftbar.menu", ["array", "null"], literal(null), { items: MENU_ITEMS }],
131
132
  ["leftbar.footer", "object", literal({}), { boundary: "sealed" }],
132
133
  ["leftbar.footer.actions", ["array", "null"], literal(null), { items: ACTION_ITEMS }],
@@ -172,6 +173,8 @@ const COLLECTION_TARGET_DEFINITIONS = [
172
173
  ["hero.enabled", ["boolean", "null"], literal(null)],
173
174
  ["hero.background", "object", literal({}), { boundary: "sealed" }],
174
175
  ["hero.background.image", ["string", "null"], literal(null)],
176
+ ["hero.background.video", ["string", "null"], literal(null)],
177
+ ["hero.background.parallax", "number", literal(0.2), { minimum: 0, maximum: 1 }],
175
178
  ["hero.background.effect", ["object", "null"], literal(null), { boundary: "registered_schema" }],
176
179
  ["hero.preview", ["object", "null"], literal(null), { boundary: "sealed" }],
177
180
  ["hero.preview.type", ["string", "null"], literal(null), { values: ["terminal", "image"] }],
@@ -209,6 +212,7 @@ const FRONT_MATTER_TARGET_DEFINITIONS = [
209
212
  ["tagline", ["string", "null"], literal(null)],
210
213
  ["banner", "object", literal({}), { boundary: "sealed" }],
211
214
  ["banner.enabled", ["boolean", "null"], literal(null)],
215
+ ["banner.background", ["boolean", "null"], literal(null)],
212
216
  ["banner.avatar", ["string", "null"], literal(null)],
213
217
  ["banner.headline", ["string", "null"], literal(null)],
214
218
  ["banner.tagline", ["string", "null"], literal(null)],
@@ -166,6 +166,7 @@ function targetNode(target) {
166
166
  ...(target.boundary === "sealed" ? { sealed: true } : {}),
167
167
  ...(target.values ? { values: clone(target.values) } : {}),
168
168
  ...(target.minimum !== undefined ? { minimum: target.minimum } : {}),
169
+ ...(target.maximum !== undefined ? { maximum: target.maximum } : {}),
169
170
  ...(target.exclusiveMinimum !== undefined ? { exclusiveMinimum: target.exclusiveMinimum } : {})
170
171
  };
171
172
  }
@@ -271,6 +272,7 @@ function decorateSharedSchemas(schema) {
271
272
  schema.properties[region].properties.brand.properties.href.validator = "nullable_safe_navigation_url";
272
273
  schema.properties[region].properties.menu.validator = "menu_items";
273
274
  }
275
+ schema.properties.leftbar.properties.menu_columns.validator = "nullable_non_negative_integer";
274
276
  schema.properties.leftbar.properties.footer.properties.actions.validator = "footer_actions";
275
277
 
276
278
  const effect = schema.properties.hero?.properties.background?.properties.effect;
@@ -206,6 +206,7 @@ function regionConfigSchemas(factory) {
206
206
  schema.type = ["object", "boolean", "null"];
207
207
  return schema;
208
208
  })(),
209
+ menuColumns: field("number", { example: 1 }),
209
210
  menu: array(menuItem, { example: [] }),
210
211
  footer: object({
211
212
  actions: array(actionItem, { example: [] })
@@ -232,6 +233,7 @@ function bannerSchema(factory) {
232
233
  const { field, object } = factory;
233
234
  return object({
234
235
  enabled: field("boolean", { example: true }),
236
+ background: field("boolean", { example: true }),
235
237
  avatar: field("string", { example: "/avatar.webp" }),
236
238
  headline: field("string", { example: "开始使用 Stellar" }),
237
239
  tagline: field("string", { example: "十分钟搭好站点" })
@@ -253,6 +255,8 @@ function heroSchema(factory, options = {}) {
253
255
  enabled: field("boolean", { example: true }),
254
256
  background: object({
255
257
  image: field("string", { example: "/hero.webp", consumers: options.pathConsumers }),
258
+ video: field("string", { example: "/hero.mp4", consumers: options.pathConsumers }),
259
+ parallax: field("number", { example: 0.2 }),
256
260
  effect: object({
257
261
  type: field("string", { example: "galaxy" }),
258
262
  options: field("object", {
@@ -20,6 +20,7 @@ hr
20
20
 
21
21
  img
22
22
  max-width: 100%
23
+ height: auto
23
24
 
24
25
  li
25
26
  font-size: 'calc(%s - 1px)' % var(--fs-content)
@@ -9,6 +9,8 @@ input.copy-area
9
9
  font-weight: $fw-700
10
10
  color: var(--text-p3)
11
11
 
12
+ // 圆环与圆点画在同一个背景层上:元素落在非整数像素位置时,border 圆环与内部盒子会被
13
+ // 分别对齐,Firefox 下圆点最多偏离圆心 1 个物理像素,因此不用 border 绘制圆环。
12
14
  input.ui-radio
13
15
  -webkit-appearance: none
14
16
  -moz-appearance: none
@@ -19,25 +21,20 @@ input.ui-radio
19
21
  width: 16px
20
22
  height: 16px
21
23
  margin: 0
22
- border: 2px solid var(--text-p3)
24
+ border: 0
23
25
  border-radius: 50%
24
26
  corner-shape: round
25
- background: none
27
+ --radio-ring: var(--text-p3)
28
+ --radio-dot-size: 0
29
+ // 外环:16px 内取 2px 环宽(半径 8px → 6px);圆点:8px(半径 4px)
30
+ background-image: radial-gradient(circle closest-side, transparent 0 5.875px, var(--radio-ring) 6.125px 100%), radial-gradient(circle closest-side, var(--theme) 0 3.875px, transparent 4.125px)
31
+ background-position: center, center
32
+ background-repeat: no-repeat, no-repeat
33
+ background-size: 16px 16px, var(--radio-dot-size) var(--radio-dot-size)
26
34
  cursor: pointer
27
- &:before
28
- content: ""
29
- display: block
30
- width: 8px
31
- height: 8px
32
- margin: 2px
33
- border-radius: 50%
34
- background: var(--theme)
35
- transform: scale(0)
36
35
  &:checked
37
- border-color: var(--theme)
38
- &:checked:before
39
- background: var(--theme)
40
- transform: scale(1)
36
+ --radio-ring: var(--theme)
37
+ --radio-dot-size: 8px
41
38
  &:focus-visible
42
39
  outline: 2px solid var(--theme)
43
40
  outline-offset: 2px
@@ -41,3 +41,20 @@ svg.loading
41
41
  .article-footer
42
42
  .loading-wrap
43
43
  background: none
44
+
45
+ .loading-wrap.navigation-loading
46
+ position: fixed
47
+ top: calc(env(safe-area-inset-top, 0px) + 1rem)
48
+ left: 50%
49
+ transform: translateX(-50%)
50
+ z-index: 1000
51
+ display: flex
52
+ align-items: center
53
+ gap: .5rem
54
+ padding: .5rem 1rem
55
+ background: var(--card)
56
+ box-shadow: 0 2px 12px rgba(0, 0, 0, .12)
57
+ pointer-events: none
58
+ .lazy-icon
59
+ flex-shrink: 0
60
+ margin: 0
@@ -54,7 +54,6 @@
54
54
  grid-column: 1 / -1
55
55
  min-width: 0
56
56
 
57
- .settings-provider-note,
58
57
  .settings-empty
59
58
  margin: 0
60
59
  color: var(--text-p2)
@@ -78,6 +78,9 @@
78
78
  inset: 0
79
79
  overflow: hidden
80
80
  background: var(--background)
81
+ &.has-parallax
82
+ top: var(--hero-parallax-inset, 0%)
83
+ bottom: var(--hero-parallax-inset, 0%)
81
84
  &.image
82
85
  .wiki-cover-background-image,
83
86
  .wiki-cover-background-blur
@@ -101,6 +104,23 @@
101
104
  inset: 0
102
105
  pointer-events: none
103
106
  background: linear-gradient(to bottom, transparent 0%, transparent 80%, var(--background) 100%)
107
+ &.has-video
108
+ background: #000000
109
+ .wiki-cover-background-video
110
+ display: block
111
+ position: absolute
112
+ z-index: 2
113
+ inset: 0
114
+ width: 100%
115
+ height: 100%
116
+ object-fit: cover
117
+ pointer-events: none
118
+ .wiki-cover-background-overlay
119
+ position: absolute
120
+ z-index: 3
121
+ inset: 0
122
+ pointer-events: none
123
+ background: rgba(black, .15)
104
124
  &.has-effect
105
125
  &:not(.image)
106
126
  background: var(--hero-effect-background, #000000)
@@ -9,3 +9,13 @@
9
9
  .menu
10
10
  .ui-collection__item
11
11
  font-size: $fs-15
12
+
13
+ .site-region--leftbar .widget-instance--menu .ui-collection[data-layout='grid']
14
+ --ui-grid-min: 0px
15
+ &[data-variant='icon']
16
+ .ui-collection__item
17
+ padding-inline: 0
18
+ .ui-collection__content
19
+ display: none
20
+ .ui-collection__initial
21
+ display: block
@@ -252,10 +252,62 @@ html[data-leftbar-state='collapsed'] .site-shell:not([data-drawer='leftbar']) .s
252
252
  background: var(--card)
253
253
  box-shadow: $boxshadow-card-float
254
254
  overflow: hidden
255
+ // 打开动画随 [open] 触发;关闭动画由 main.js 添加 .is-closing 并在动画结束后调用 close()
256
+ &[open]
257
+ animation: search-dialog-in 0.24s ease-out backwards
258
+ &.is-closing
259
+ animation: search-dialog-out 0.18s ease-in both
255
260
  &::backdrop
256
261
  background: rgba(black, .38)
257
262
  backdrop-filter: blur(8px)
258
263
  -webkit-backdrop-filter: blur(8px)
264
+ animation: search-backdrop-in 0.24s ease-out backwards
265
+ &.is-closing::backdrop
266
+ animation: search-backdrop-out 0.18s ease-in both
267
+
268
+ @keyframes search-dialog-in
269
+ from
270
+ opacity: 0
271
+ transform: translateY(8px) scale(.97)
272
+ to
273
+ opacity: 1
274
+ transform: none
275
+
276
+ @keyframes search-dialog-out
277
+ from
278
+ opacity: 1
279
+ transform: none
280
+ to
281
+ opacity: 0
282
+ transform: translateY(8px) scale(.97)
283
+
284
+ @keyframes search-backdrop-in
285
+ from
286
+ opacity: 0
287
+ to
288
+ opacity: 1
289
+
290
+ @keyframes search-backdrop-out
291
+ from
292
+ opacity: 1
293
+ to
294
+ opacity: 0
295
+
296
+ @keyframes search-dialog-in-fluid
297
+ from
298
+ opacity: 0
299
+ transform: translateY(16px)
300
+ to
301
+ opacity: 1
302
+ transform: none
303
+
304
+ @keyframes search-dialog-out-fluid
305
+ from
306
+ opacity: 1
307
+ transform: none
308
+ to
309
+ opacity: 0
310
+ transform: translateY(16px)
259
311
 
260
312
  .search-dialog__panel
261
313
  box-sizing: border-box
@@ -359,6 +411,9 @@ html[data-leftbar-state='collapsed'] .site-shell:not([data-drawer='leftbar']) .s
359
411
 
360
412
  html[data-search-open]
361
413
  overflow: hidden
414
+ // 锁定滚动会移除常驻滚动条槽位,按 main.js 实测宽度补位,避免页面内容宽度变化后重排
415
+ body
416
+ padding-right: var(--stellar-scrollbar-gutter, 0px)
362
417
 
363
418
  @media screen and (max-width: $device-tablet)
364
419
  .search-dialog
@@ -367,6 +422,11 @@ html[data-search-open]
367
422
  max-height: none
368
423
  border: 0
369
424
  border-radius: 0
425
+ // 全屏浮层不做缩放,避免露出被裁切的边缘
426
+ &[open]
427
+ animation-name: search-dialog-in-fluid
428
+ &.is-closing
429
+ animation-name: search-dialog-out-fluid
370
430
  .search-dialog__panel
371
431
  height: 100%
372
432
  padding: unquote('max(12px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) max(12px, env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left))')
@@ -375,6 +435,10 @@ html[data-search-open]
375
435
 
376
436
  @media (prefers-reduced-motion: reduce)
377
437
  .search-dialog,
378
- .search-dialog::backdrop
438
+ .search-dialog[open],
439
+ .search-dialog.is-closing,
440
+ .search-dialog::backdrop,
441
+ .search-dialog[open]::backdrop,
442
+ .search-dialog.is-closing::backdrop
379
443
  animation: none
380
444
  transition: none
@@ -38,7 +38,7 @@
38
38
  top: 4px
39
39
  left: 0
40
40
  width: 4px
41
- height: unquote('max(0px, calc(var(--toc-active-height, 8px) - 8px))')
41
+ height: calc(var(--toc-active-height, 8px) - 8px)
42
42
  border-radius: 4px
43
43
  background: var(--theme)
44
44
  opacity: var(--toc-active-opacity, 0)
@@ -185,7 +185,9 @@
185
185
  height: 2.5rem
186
186
  app-icon()
187
187
  background: var(--block)
188
- color: var(--text-p2)
188
+ // 未配置 icon 时的默认图标:颜色跟随封面平均色自适应(深色封面显示为接近白色)。
189
+ // --text-banner 只在有封面时由 adaptive-text 写入 .wiki-card-info,无封面时回退到次要文字色。
190
+ color: var(--text-banner, var(--text-p2))
189
191
  img
190
192
  width: 100%
191
193
  height: 100%
package/source/js/main.js CHANGED
@@ -103,6 +103,10 @@ const rightbarDrawerQuery = '(max-width: 1180px)';
103
103
  let shellDrawerTrigger = null;
104
104
  let searchDialogTrigger = null;
105
105
  let searchDialogRestoreFocus = true;
106
+ // 退场动画的安全兜底时长,需大于 search.styl 中 search-dialog-out 的 0.18s
107
+ const searchCloseFallbackDelay = 260;
108
+ let searchCloseTimer = null;
109
+ let searchCloseListener = null;
106
110
  let shellInputModality = 'pointer';
107
111
 
108
112
  function regionElement(region) {
@@ -244,6 +248,11 @@ function applySearchScope(dialog, value, refresh = true) {
244
248
  }
245
249
  }
246
250
 
251
+ function searchMotionEnabled() {
252
+ if (typeof window.matchMedia !== 'function') return true;
253
+ return !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
254
+ }
255
+
247
256
  function configureSearchScope(dialog, trigger) {
248
257
  const group = dialog?.querySelector('.search-dialog__scope');
249
258
  if (!group) return false;
@@ -265,10 +274,30 @@ function configureSearchScope(dialog, trigger) {
265
274
  return true;
266
275
  }
267
276
 
268
- function closeSearch() {
269
- const dialog = searchDialogElement();
270
- if (!dialog?.open) return;
271
- dialog.close();
277
+ function clearSearchClosePending(dialog) {
278
+ if (searchCloseTimer !== null) {
279
+ clearTimeout(searchCloseTimer);
280
+ searchCloseTimer = null;
281
+ }
282
+ if (searchCloseListener) {
283
+ dialog.removeEventListener('animationend', searchCloseListener);
284
+ searchCloseListener = null;
285
+ }
286
+ dialog.classList.remove('is-closing');
287
+ }
288
+
289
+ // 锁定页面滚动会移除常驻滚动条,先把槽位宽度写进 CSS 变量供补位使用
290
+ function syncSearchScrollbarGutter() {
291
+ const root = document.documentElement;
292
+ if (root.hasAttribute('data-search-open')) return;
293
+ const gutter = Math.max(0, window.innerWidth - root.clientWidth);
294
+ root.style.setProperty('--stellar-scrollbar-gutter', gutter + 'px');
295
+ }
296
+
297
+ // 关闭动画播放期间浮层仍处于打开状态,退出动画结束或兜底超时后才真正 close 并恢复焦点
298
+ function finishSearchClose(dialog) {
299
+ clearSearchClosePending(dialog);
300
+ if (dialog.open) dialog.close();
272
301
  document.documentElement.removeAttribute('data-search-open');
273
302
  if (searchDialogRestoreFocus && searchDialogTrigger?.focus) searchDialogTrigger.focus();
274
303
  else if (searchDialogTrigger?.blur) searchDialogTrigger.blur();
@@ -276,12 +305,29 @@ function closeSearch() {
276
305
  searchDialogRestoreFocus = true;
277
306
  }
278
307
 
308
+ function closeSearch() {
309
+ const dialog = searchDialogElement();
310
+ if (!dialog?.open || searchCloseTimer !== null) return;
311
+ if (!searchMotionEnabled()) {
312
+ finishSearchClose(dialog);
313
+ return;
314
+ }
315
+ searchCloseListener = function (event) {
316
+ if (event.animationName !== 'search-dialog-out') return;
317
+ finishSearchClose(dialog);
318
+ };
319
+ dialog.classList.add('is-closing');
320
+ dialog.addEventListener('animationend', searchCloseListener);
321
+ searchCloseTimer = setTimeout(function () { finishSearchClose(dialog); }, searchCloseFallbackDelay);
322
+ }
323
+
279
324
  function openSearch(trigger, restoreFocus = true) {
280
325
  const dialog = searchDialogElement();
281
326
  const input = dialog?.querySelector('.search-input');
282
327
  const wrapper = dialog?.querySelector('.search-wrapper');
283
328
  const result = dialog?.querySelector('.search-result');
284
329
  if (!dialog || !input) return;
330
+ clearSearchClosePending(dialog);
285
331
  searchDialogTrigger = trigger || document.activeElement;
286
332
  searchDialogRestoreFocus = restoreFocus;
287
333
  input.value = '';
@@ -294,6 +340,7 @@ function openSearch(trigger, restoreFocus = true) {
294
340
  }
295
341
  result?.replaceChildren();
296
342
  if (!dialog.open) dialog.showModal();
343
+ syncSearchScrollbarGutter();
297
344
  document.documentElement.setAttribute('data-search-open', '');
298
345
  input.focus();
299
346
  }
@@ -2,7 +2,7 @@
2
2
  var SUPPORTED_PROVIDERS = ['artalk', 'waline', 'twikoo'];
3
3
  var SETTINGS_ICON = 'default:settings';
4
4
  var PROFILE_ICON = 'default:profile';
5
- var GRAVATAR_BASE_URL = 'https://gravatar.com/avatar/';
5
+ var GRAVATAR_BASE_URL = window.stellarClientData.ctx.services.gravatar.baseUrl.replace(/\/+$/, '') + '/';
6
6
  var localStore = null;
7
7
  var sessionStore = null;
8
8
  var gravatarHashes = new Map();
@@ -0,0 +1,70 @@
1
+ const SELECTOR = '.wiki-hero';
2
+
3
+ export function mount(root, context = {}) {
4
+ const heroes = [...root.querySelectorAll(SELECTOR)];
5
+ if (root.matches?.(SELECTOR)) heroes.unshift(root);
6
+ const cleanups = heroes.map(hero => {
7
+ const background = hero.querySelector('.wiki-cover-background');
8
+ if (!background?.matches('.image, .has-video, .has-effect')) return () => {};
9
+ const configuredStrength = Number(background.getAttribute('data-parallax'));
10
+ const strength = Number.isFinite(configuredStrength) ? Math.max(0, Math.min(1, configuredStrength)) : 0;
11
+ const fades = background.matches('.has-video, .image:not(.has-effect)');
12
+ const documentRef = hero.ownerDocument;
13
+ const windowRef = documentRef.defaultView;
14
+ const motion = windowRef.matchMedia('(prefers-reduced-motion: reduce)');
15
+ let frame = null;
16
+ let visible = true;
17
+
18
+ function update() {
19
+ frame = null;
20
+ const rect = hero.getBoundingClientRect();
21
+ if (fades) {
22
+ const opacity = rect.height > 0 ? Math.max(0, Math.min(1, (rect.top + rect.height) / rect.height)) : 1;
23
+ background.style.opacity = String(opacity);
24
+ }
25
+ if (motion.matches || strength === 0) {
26
+ background.style.removeProperty('transform');
27
+ background.style.removeProperty('--hero-parallax-inset');
28
+ background.classList.remove('has-parallax');
29
+ return;
30
+ }
31
+ background.style.setProperty('--hero-parallax-inset', `${-strength * 100}%`);
32
+ background.classList.add('has-parallax');
33
+ const offset = Math.max(-rect.height * strength, Math.min(rect.height * strength, -rect.top * strength));
34
+ background.style.transform = `translate3d(0, ${offset}px, 0)`;
35
+ }
36
+ function schedule() {
37
+ if (frame === null && visible && !documentRef.hidden) frame = windowRef.requestAnimationFrame(update);
38
+ }
39
+ const observer = new windowRef.IntersectionObserver(entries => {
40
+ visible = entries[0].isIntersecting;
41
+ // Commit the endpoint even when a large scroll moves the Hero offscreen.
42
+ if (frame !== null) windowRef.cancelAnimationFrame(frame);
43
+ update();
44
+ });
45
+ observer.observe(hero);
46
+ const resize = new windowRef.ResizeObserver(schedule);
47
+ resize.observe(hero);
48
+ windowRef.addEventListener('scroll', schedule, { passive: true });
49
+ windowRef.addEventListener('resize', schedule);
50
+ documentRef.addEventListener('visibilitychange', schedule);
51
+ motion.addEventListener('change', schedule);
52
+ update();
53
+ return () => {
54
+ if (frame !== null) windowRef.cancelAnimationFrame(frame);
55
+ observer.disconnect();
56
+ resize.disconnect();
57
+ windowRef.removeEventListener('scroll', schedule);
58
+ windowRef.removeEventListener('resize', schedule);
59
+ documentRef.removeEventListener('visibilitychange', schedule);
60
+ motion.removeEventListener('change', schedule);
61
+ background.style.removeProperty('transform');
62
+ background.style.removeProperty('--hero-parallax-inset');
63
+ if (fades) background.style.removeProperty('opacity');
64
+ background.classList.remove('has-parallax');
65
+ };
66
+ });
67
+ const cleanup = () => cleanups.forEach(dispose => dispose());
68
+ context.onCleanup?.(cleanup);
69
+ return cleanup;
70
+ }
@@ -79,6 +79,20 @@ export function mount(root, context) {
79
79
  }
80
80
  async function navigate(url, { pop = false, position } = {}) {
81
81
  const request = queue.begin();
82
+ const loading = document.createElement('div');
83
+ loading.className = 'loading-wrap navigation-loading';
84
+ loading.setAttribute('role', 'status');
85
+ const icon = document.createElement('div');
86
+ icon.className = 'lazy-icon';
87
+ icon.setAttribute('aria-hidden', 'true');
88
+ loading.append(icon);
89
+ // Fast navigations should finish without flashing a loading indicator.
90
+ const loadingTimer = setTimeout(() => document.body.append(loading), 150);
91
+ const clearLoading = () => {
92
+ clearTimeout(loadingTimer);
93
+ loading.remove();
94
+ };
95
+ request.signal.addEventListener('abort', clearLoading, { once: true });
82
96
  let timedOut = false;
83
97
  const timeout = setTimeout(() => { timedOut = true; request.abort(); }, 15000);
84
98
  if (!pop) remember();
@@ -134,7 +148,10 @@ export function mount(root, context) {
134
148
  });
135
149
  } catch (error) {
136
150
  if ((!request.signal.aborted || timedOut) && !lifetime.signal.aborted) fallback(url, pop);
137
- } finally { clearTimeout(timeout); }
151
+ } finally {
152
+ clearTimeout(timeout);
153
+ clearLoading();
154
+ }
138
155
  }
139
156
 
140
157
  document.addEventListener('click', event => {
@@ -90,7 +90,6 @@ export function mount(root, context) {
90
90
  const provider = String(page.dataset.provider || '').toLowerCase();
91
91
  const api = globalThis.window?.stellarProfile;
92
92
  const form = page.querySelector('[data-profile-form]');
93
- const note = page.querySelector('[data-provider-note]');
94
93
  const status = page.querySelector('[data-profile-status]');
95
94
  const avatar = page.querySelector('[data-profile-avatar]');
96
95
  const avatarFallback = page.querySelector('[data-profile-avatar-fallback]');
@@ -174,8 +173,8 @@ export function mount(root, context) {
174
173
  if (urlInput) urlInput.value = identity.url || '';
175
174
  if (form) form.hidden = !supported;
176
175
  if (logoutButton) logoutButton.hidden = !supported;
177
- if (note) note.hidden = supported;
178
- renderAvatar(identity);
176
+ if (supported) renderAvatar(identity);
177
+ else if (avatar) avatar.hidden = true;
179
178
  }
180
179
 
181
180
  function onSubmit(event) {