hexo-theme-stellar 2.0.0-rc.5 → 2.0.0-rc.6
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/CHANGELOG.md +26 -0
- package/README.md +8 -0
- package/README_EN.md +8 -0
- package/_config.yml +6 -0
- package/layout/_partial/cover/wiki_cover.ejs +9 -3
- package/layout/_partial/primitives/navigation.ejs +3 -2
- package/layout/_partial/regions/widgets.ejs +1 -1
- package/layout/_partial/sidebar/menu.ejs +3 -0
- package/legal/THIRD-PARTY-NOTICES.md +2 -2
- package/package.json +1 -1
- package/scripts/lib/client-assets.js +1 -0
- package/scripts/lib/config-schema.js +2 -2
- package/scripts/lib/contribution-registry.js +14 -0
- package/scripts/lib/hero-effect-registry.js +50 -1
- package/scripts/lib/internal-constants.js +1 -0
- package/scripts/lib/models/wiki.js +2 -0
- package/scripts/lib/regions.js +8 -1
- package/scripts/schema/config-rules.js +3 -0
- package/scripts/schema/content-config-rules.js +3 -0
- package/scripts/schema/content-config-schema.js +2 -0
- package/scripts/schema/model-schema.js +3 -0
- package/source/css/_common/loading.styl +17 -0
- package/source/css/_components/partial/cover.styl +20 -0
- package/source/css/_components/sidebar/nav-area.styl +10 -0
- package/source/css/_components/widgets/toc.styl +1 -1
- package/source/js/profile.js +1 -1
- package/source/js/runtime/extensions/hero-parallax.js +70 -0
- package/source/js/runtime/extensions/partial-navigation.js +18 -1
- package/source/js/runtime/hero-effects/strands.js +302 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.0.0-rc.6
|
|
4
|
+
|
|
5
|
+
> 发布日期:2026-09-18
|
|
6
|
+
|
|
7
|
+
本节记录从公开候选 `2.0.0-rc.5` 到 `2.0.0-rc.6` 最终文件树的净变化。
|
|
8
|
+
|
|
9
|
+
### 新功能
|
|
10
|
+
|
|
11
|
+
- Wiki Hero 新增视频背景 `hero.background.video`,静音循环自动播放,图片可用作 poster;设置视频时不加载动态效果。
|
|
12
|
+
- Hero 新增滚动视差 `hero.background.parallax`,范围 0–1、默认 0.2,设为 0 关闭位移;图片与视频背景随滚动淡出,减少动态效果偏好下关闭视差位移。
|
|
13
|
+
- 新增 Strands WebGL 背景效果,支持光带配色、形态、发光及玻璃折射参数。
|
|
14
|
+
- Leftbar 新增 `menu_columns`,支持 1–5 列,默认 1;1–2 列显示图标与标题,3–5 列仅显示图标并保留无障碍名称。支持 Profile、Collection、Page 覆盖。
|
|
15
|
+
- 新增 `services.gravatar.base_url`,侧栏与设置页共享访客头像镜像地址,默认 `https://gravatar.com/avatar/`;自定义服务需兼容 SHA-256 并包含 `/avatar/` 路径。
|
|
16
|
+
|
|
17
|
+
### 修复与改进
|
|
18
|
+
|
|
19
|
+
- 同集合页面切换等待超过 150ms 时显示加载反馈,完成、失败或取消后清理,快速切换不闪烁。
|
|
20
|
+
- 修复目录活动指示条在 CSS 压缩后高度丢失的问题。
|
|
21
|
+
- 中英文安装说明补充 Windows PowerShell Blueprint 安装命令。
|
|
22
|
+
|
|
23
|
+
### 升级注意
|
|
24
|
+
|
|
25
|
+
- 现有 Hero 背景默认启用 0.2 强度视差;如需关闭位移,显式设置 `hero.background.parallax: 0`。
|
|
26
|
+
|
|
27
|
+
Full Changelog: [2.0.0-rc.5...2.0.0-rc.6](https://github.com/xaoxuu/hexo-theme-stellar/compare/2.0.0-rc.5...2.0.0-rc.6)
|
|
28
|
+
|
|
3
29
|
## 2.0.0-rc.5
|
|
4
30
|
|
|
5
31
|
> 发布日期:2026-09-12
|
package/README.md
CHANGED
|
@@ -49,10 +49,18 @@
|
|
|
49
49
|
|
|
50
50
|
准备好 Git 和 npm 后,一条命令即可启动交互式蓝图安装器:
|
|
51
51
|
|
|
52
|
+
macOS / Linux:
|
|
53
|
+
|
|
52
54
|
```bash
|
|
53
55
|
sh -c "$(curl -fsSL https://github.com/xaoxuu/hexo-theme-stellar-examples/raw/main/install.sh)"
|
|
54
56
|
```
|
|
55
57
|
|
|
58
|
+
Windows PowerShell 7+(`pwsh`):
|
|
59
|
+
|
|
60
|
+
```powershell
|
|
61
|
+
& ([scriptblock]::Create((Invoke-RestMethod https://github.com/xaoxuu/hexo-theme-stellar-examples/raw/main/install.ps1)))
|
|
62
|
+
```
|
|
63
|
+
|
|
56
64
|
安装器会读取示例仓库的 [`blueprints.json`](https://github.com/xaoxuu/hexo-theme-stellar-examples/blob/main/blueprints.json),展示当前可用的蓝图,并依次确认蓝图、项目目录、依赖安装和创建计划。
|
|
57
65
|
|
|
58
66
|
### 从 npm 安装
|
package/README_EN.md
CHANGED
|
@@ -49,10 +49,18 @@ Your writing preserves ideas while external data stays current. Remote content,
|
|
|
49
49
|
|
|
50
50
|
With Git and npm available, launch the interactive Blueprint installer with one command:
|
|
51
51
|
|
|
52
|
+
macOS / Linux:
|
|
53
|
+
|
|
52
54
|
```bash
|
|
53
55
|
sh -c "$(curl -fsSL https://github.com/xaoxuu/hexo-theme-stellar-examples/raw/main/install.sh)"
|
|
54
56
|
```
|
|
55
57
|
|
|
58
|
+
Windows PowerShell 7+ (`pwsh`):
|
|
59
|
+
|
|
60
|
+
```powershell
|
|
61
|
+
& ([scriptblock]::Create((Invoke-RestMethod https://github.com/xaoxuu/hexo-theme-stellar-examples/raw/main/install.ps1)))
|
|
62
|
+
```
|
|
63
|
+
|
|
56
64
|
The installer reads the example repository's [`blueprints.json`](https://github.com/xaoxuu/hexo-theme-stellar-examples/blob/main/blueprints.json), presents the available Blueprints, then confirms the Blueprint, project directory, dependency installation, and creation plan.
|
|
57
65
|
|
|
58
66
|
### Install from npm
|
package/_config.yml
CHANGED
|
@@ -58,6 +58,8 @@ leftbar:
|
|
|
58
58
|
tagline: null
|
|
59
59
|
# 设置品牌链接。
|
|
60
60
|
href: /
|
|
61
|
+
# 设置 Leftbar 菜单列数(1–5);1–2 列显示图标与标题,3–5 列仅显示居中的图标。
|
|
62
|
+
menu_columns: 1
|
|
61
63
|
# 设置 Leftbar 菜单项;数组会整体替换,空数组表示不显示菜单项。
|
|
62
64
|
menu:
|
|
63
65
|
# 声明默认的博客入口。
|
|
@@ -930,6 +932,10 @@ features:
|
|
|
930
932
|
|
|
931
933
|
# 配置主题使用的数据服务。
|
|
932
934
|
services:
|
|
935
|
+
# 配置访客身份头像使用的 Gravatar 服务(侧边栏与设置页共享)。
|
|
936
|
+
gravatar:
|
|
937
|
+
# 设置兼容 SHA-256 的头像镜像地址,包含 /avatar/ 路径;末尾斜杠可省略。
|
|
938
|
+
base_url: https://gravatar.com/avatar/
|
|
933
939
|
# 配置所有动态 Markdown 内容共享的 Marked 客户端。
|
|
934
940
|
markdown:
|
|
935
941
|
# 自定义脚本地址;null 使用默认资源。
|
|
@@ -13,6 +13,7 @@ function layoutWikiCover() {
|
|
|
13
13
|
}
|
|
14
14
|
var backgroundConfig = cover.background || {};
|
|
15
15
|
var background = typeof backgroundConfig.image === 'string' && backgroundConfig.image.length > 0 ? backgroundConfig.image : '';
|
|
16
|
+
var video = typeof backgroundConfig.video === 'string' ? backgroundConfig.video.trim() : '';
|
|
16
17
|
var effect = backgroundConfig.effect || {};
|
|
17
18
|
var preview = cover.preview || {};
|
|
18
19
|
var commands = Array.isArray(preview.commands) ? preview.commands.filter(function (item) {
|
|
@@ -25,7 +26,7 @@ function layoutWikiCover() {
|
|
|
25
26
|
var actions = Array.isArray(cover.actions) ? cover.actions.filter(function (item) {
|
|
26
27
|
return item && item.title && item.url;
|
|
27
28
|
}) : [];
|
|
28
|
-
var hasEffect = typeof effect.type === 'string' && effect.type.length > 0;
|
|
29
|
+
var hasEffect = !video && typeof effect.type === 'string' && effect.type.length > 0;
|
|
29
30
|
var isImageBackground = background.length > 0;
|
|
30
31
|
var effectOptions = effect.options && typeof effect.options === 'object' && !Array.isArray(effect.options) ? effect.options : {};
|
|
31
32
|
var effectRuntime = effect.runtime && typeof effect.runtime === 'object' && !Array.isArray(effect.runtime) ? effect.runtime : {};
|
|
@@ -37,18 +38,23 @@ function layoutWikiCover() {
|
|
|
37
38
|
var effectRuntimeJson = escape_html(JSON.stringify(effectRuntime));
|
|
38
39
|
var projectName = cover.projectName;
|
|
39
40
|
var adaptiveTextAttr = '';
|
|
40
|
-
if (
|
|
41
|
+
if (video) {
|
|
42
|
+
adaptiveTextAttr = ' data-text-adaptive="split" style="--adaptive-background:#000000"';
|
|
43
|
+
} else if (isImageBackground) {
|
|
41
44
|
adaptiveTextAttr = ' data-text-adaptive="split" style="--cover-url:url(\'' + escape_html(background.replace(/'/g, '%27')) + '\')"';
|
|
42
45
|
} else if (hasEffect) {
|
|
43
46
|
adaptiveTextAttr = ' data-text-adaptive="split" style="--adaptive-background:' + escape_html(effectBackground) + '"';
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
el += '<div class="cover-content wiki wiki-hero' + (hasPreview ? '' : ' without-preview') + '">';
|
|
47
|
-
el += '<div class="wiki-cover-background' + (hasEffect ? ' has-effect' : '') + (respectsReducedMotion ? ' respect-reduced-motion' : '') + (isImageBackground ? ' image' : '') + '"' + (hasEffect ? ' style="--hero-effect-background:' + escape_html(effectBackground) + '"' : '') + ' aria-hidden="true">';
|
|
50
|
+
el += '<div class="wiki-cover-background' + (hasEffect ? ' has-effect' : '') + (respectsReducedMotion ? ' respect-reduced-motion' : '') + (isImageBackground ? ' image' : '') + (video ? ' has-video' : '') + '" data-parallax="' + escape_html(String(backgroundConfig.parallax)) + '"' + (hasEffect ? ' style="--hero-effect-background:' + escape_html(effectBackground) + '"' : '') + ' aria-hidden="true">';
|
|
48
51
|
if (isImageBackground) {
|
|
49
52
|
el += '<img class="wiki-cover-background-image" src="' + escape_html(background) + '" alt="" />';
|
|
50
53
|
el += '<img class="wiki-cover-background-blur" src="' + escape_html(background) + '" alt="" />';
|
|
51
54
|
}
|
|
55
|
+
if (video) {
|
|
56
|
+
el += '<video class="wiki-cover-background-video" src="' + escape_html(video) + '"' + (isImageBackground ? ' poster="' + escape_html(background) + '"' : '') + ' autoplay muted loop playsinline preload="metadata" disablepictureinpicture tabindex="-1"></video>';
|
|
57
|
+
}
|
|
52
58
|
if (hasEffect) {
|
|
53
59
|
el += '<canvas data-hero-effect="' + escape_html(effect.type) + '" data-effect-resource="' + effectResourceJson + '" data-effect-options="' + effectOptionsJson + '" data-effect-runtime="' + effectRuntimeJson + '"></canvas>';
|
|
54
60
|
}
|
|
@@ -5,8 +5,9 @@ if (!allowedPlacements.includes(placement)) {
|
|
|
5
5
|
}
|
|
6
6
|
var navigationItems = Array.isArray(entries) ? entries : []
|
|
7
7
|
var navigationBody = partial('_partial/components/collection', {options: {
|
|
8
|
-
layout: 'list',
|
|
9
|
-
|
|
8
|
+
layout: placement === 'leftbar' && typeof columns !== 'undefined' && columns > 1 ? 'grid' : 'list',
|
|
9
|
+
columns: typeof columns !== 'undefined' ? columns : 1,
|
|
10
|
+
variant: placement === 'leftbar' && typeof columns !== 'undefined' && columns > 2 ? 'icon' : 'nav',
|
|
10
11
|
items: navigationItems
|
|
11
12
|
}})
|
|
12
13
|
var html = ''
|
|
@@ -69,7 +69,7 @@ function stack(items, zone, options) {
|
|
|
69
69
|
<% if (leftbarBrandBody.trim().length > 0) { %><div class="widget-instance widget-instance--brand" data-widget-id="leftbar:fixed:brand" data-presentation="leftbar" data-leftbar-rail><%- leftbarBrandBody %></div><% } %>
|
|
70
70
|
</header>
|
|
71
71
|
<div class="site-region__body site-region__zone site-region__zone--body">
|
|
72
|
-
<% if (menuItems.length > 0) { %><div class="widget-instance widget-instance--menu" data-widget-id="leftbar:fixed:menu" data-presentation="leftbar" data-leftbar-rail><%- partial('../sidebar/menu', {where: 'leftbar', menu: menuItems, activeMenu: activeViewModel?.item.navigation.menu, viewModel: activeViewModel}) %></div><% } %>
|
|
72
|
+
<% if (menuItems.length > 0) { %><div class="widget-instance widget-instance--menu" data-widget-id="leftbar:fixed:menu" data-presentation="leftbar" data-leftbar-rail><%- partial('../sidebar/menu', {where: 'leftbar', menu: menuItems, menuColumns: regionState.menuColumns, activeMenu: activeViewModel?.item.navigation.menu, viewModel: activeViewModel}) %></div><% } %>
|
|
73
73
|
<%- stack(regionWidgets, 'body') %>
|
|
74
74
|
</div>
|
|
75
75
|
<footer class="site-region__footer">
|
|
@@ -9,6 +9,7 @@ function layoutDiv() {
|
|
|
9
9
|
var pathMenu = menu_active_by_path(page.path, menuItems)
|
|
10
10
|
if (pathMenu != null) currentMenu = pathMenu
|
|
11
11
|
}
|
|
12
|
+
var iconLayout = where === 'leftbar' && typeof menuColumns !== 'undefined' && menuColumns > 2
|
|
12
13
|
var entries = []
|
|
13
14
|
for (var menuItem of menuItems) {
|
|
14
15
|
if (menuItem == null || menuItem.id == null || menuItem.url == null || menuItem.title == null) {
|
|
@@ -18,6 +19,7 @@ function layoutDiv() {
|
|
|
18
19
|
href: pretty_url(menuItem.url),
|
|
19
20
|
title: __(menuItem.title),
|
|
20
21
|
icon: menuItem.icon,
|
|
22
|
+
attrs: iconLayout ? {'aria-label': __(menuItem.title)} : {},
|
|
21
23
|
theme: menuItem.accent,
|
|
22
24
|
active: menuItem.id == currentMenu,
|
|
23
25
|
className: menuItem.accent?.length > 0 ? 'is-themed' : ''
|
|
@@ -25,6 +27,7 @@ function layoutDiv() {
|
|
|
25
27
|
}
|
|
26
28
|
return partial('_partial/primitives/navigation', {
|
|
27
29
|
placement: ['main', 'topbar', 'rightbar'].includes(where) ? where : 'leftbar',
|
|
30
|
+
columns: where === 'leftbar' && typeof menuColumns !== 'undefined' ? menuColumns : 1,
|
|
28
31
|
entries: entries
|
|
29
32
|
})
|
|
30
33
|
}
|
|
@@ -5,10 +5,10 @@ This project includes or adapts portions of the following third-party software.
|
|
|
5
5
|
## React Bits
|
|
6
6
|
|
|
7
7
|
- Project: [React Bits](https://github.com/DavidHDev/react-bits)
|
|
8
|
-
- Components: Ferrofluid, Galaxy and Spotlight Card
|
|
8
|
+
- Components: Ferrofluid, Galaxy, Strands and Spotlight Card
|
|
9
9
|
- License: MIT
|
|
10
10
|
- Copyright: Copyright (c) 2026 David Haz
|
|
11
|
-
- Usage: Adapted for the Wiki Ferrofluid and
|
|
11
|
+
- Usage: Adapted for the Wiki Ferrofluid, Galaxy and Strands WebGL backgrounds and Card Hover spotlight layer.
|
|
12
12
|
|
|
13
13
|
### MIT License
|
|
14
14
|
|
package/package.json
CHANGED
|
@@ -22,6 +22,7 @@ function clientAssets(hexo) {
|
|
|
22
22
|
'default:settings', 'copy:copy', 'weibo:repeat', 'weibo:like'];
|
|
23
23
|
const clientIcons = Object.fromEntries(keys.map(key => [key, (icons[key] || '').replace(/<!--[\s\S]*?-->/g, '')]));
|
|
24
24
|
const services = JSON.parse(JSON.stringify(INTERNAL.assets.services));
|
|
25
|
+
services.gravatar = { baseUrl: config.services.gravatar.baseUrl };
|
|
25
26
|
services.siteinfo.api = resolveServiceProvider(config.services.siteInfo)?.endpoint || null;
|
|
26
27
|
const provider = config.search.provider;
|
|
27
28
|
const search = provider ? { service: provider === 'local' ? 'local_search' : provider } : null;
|
|
@@ -256,11 +256,11 @@ function validateEffectOptions(node, definition, options, source, path, issues)
|
|
|
256
256
|
issues.push(issue("invalid_value", source, optionPath, valueType(value), "six-digit hexadecimal color", node.migration));
|
|
257
257
|
} else if (rule.type === "hex_color_array" && (
|
|
258
258
|
!Array.isArray(value)
|
|
259
|
-
|| value.length < 1
|
|
259
|
+
|| value.length < (rule.minItems ?? 1)
|
|
260
260
|
|| value.length > 8
|
|
261
261
|
|| value.some(item => typeof item !== "string" || !/^#?[a-f\d]{6}$/i.test(item))
|
|
262
262
|
)) {
|
|
263
|
-
issues.push(issue("invalid_value", source, optionPath, valueType(value),
|
|
263
|
+
issues.push(issue("invalid_value", source, optionPath, valueType(value), `${rule.minItems ?? 1} to eight six-digit hexadecimal colors`, node.migration));
|
|
264
264
|
}
|
|
265
265
|
}
|
|
266
266
|
}
|
|
@@ -139,6 +139,20 @@ const CONTRIBUTIONS = defineContributions([
|
|
|
139
139
|
});
|
|
140
140
|
}
|
|
141
141
|
},
|
|
142
|
+
{
|
|
143
|
+
id: "hero-parallax",
|
|
144
|
+
scope: "region",
|
|
145
|
+
kind: "feature",
|
|
146
|
+
entry: runtimeEntry("/js/runtime/extensions/hero-parallax.js"),
|
|
147
|
+
resources: [],
|
|
148
|
+
activation: selector(".wiki-hero"),
|
|
149
|
+
schema: null,
|
|
150
|
+
i18n: null,
|
|
151
|
+
docs: { category: "Components", path: "docs/knowledge/03-内容系统/wiki-docs.md" },
|
|
152
|
+
tests: [RUNTIME_TEST, RUNTIME_CONSUMPTION_TEST],
|
|
153
|
+
defaultsOwner: null,
|
|
154
|
+
project: () => configResult({})
|
|
155
|
+
},
|
|
142
156
|
{
|
|
143
157
|
id: "hero-effect",
|
|
144
158
|
scope: "region",
|
|
@@ -32,7 +32,7 @@ function optionMatches(rule, value) {
|
|
|
32
32
|
if (rule.type === "hex_color") return typeof value === "string" && /^#?[a-f\d]{6}$/i.test(value);
|
|
33
33
|
if (rule.type === "hex_color_array") {
|
|
34
34
|
return Array.isArray(value)
|
|
35
|
-
&& value.length >= 1
|
|
35
|
+
&& value.length >= (rule.minItems ?? 1)
|
|
36
36
|
&& value.length <= 8
|
|
37
37
|
&& value.every(item => typeof item === "string" && /^#?[a-f\d]{6}$/i.test(item));
|
|
38
38
|
}
|
|
@@ -91,6 +91,55 @@ const LIGHT_RAYS_ORIGINS = Object.freeze([
|
|
|
91
91
|
]);
|
|
92
92
|
|
|
93
93
|
const DEFINITIONS = defineHeroEffects([
|
|
94
|
+
{
|
|
95
|
+
id: "strands",
|
|
96
|
+
label: "Strands",
|
|
97
|
+
module: INTERNAL.assets.heroEffects.strands,
|
|
98
|
+
options: {
|
|
99
|
+
colors: { type: "hex_color_array", minItems: 0 },
|
|
100
|
+
count: { type: "number" },
|
|
101
|
+
speed: { type: "number" },
|
|
102
|
+
amplitude: { type: "number" },
|
|
103
|
+
waviness: { type: "number" },
|
|
104
|
+
thickness: { type: "number" },
|
|
105
|
+
glow: { type: "number" },
|
|
106
|
+
taper: { type: "number" },
|
|
107
|
+
spread: { type: "number" },
|
|
108
|
+
hueShift: { type: "number" },
|
|
109
|
+
intensity: { type: "number" },
|
|
110
|
+
saturation: { type: "number" },
|
|
111
|
+
opacity: { type: "number" },
|
|
112
|
+
scale: { type: "number" },
|
|
113
|
+
glass: { type: "boolean" },
|
|
114
|
+
refraction: { type: "number" },
|
|
115
|
+
dispersion: { type: "number" },
|
|
116
|
+
glassSize: { type: "number" }
|
|
117
|
+
},
|
|
118
|
+
defaults: {
|
|
119
|
+
colors: ["#F97316", "#7C3AED", "#06B6D4"],
|
|
120
|
+
count: 3,
|
|
121
|
+
speed: 0.5,
|
|
122
|
+
amplitude: 1,
|
|
123
|
+
waviness: 1,
|
|
124
|
+
thickness: 0.7,
|
|
125
|
+
glow: 2.6,
|
|
126
|
+
taper: 3,
|
|
127
|
+
spread: 1,
|
|
128
|
+
hueShift: 0,
|
|
129
|
+
intensity: 0.6,
|
|
130
|
+
saturation: 1.5,
|
|
131
|
+
opacity: 1,
|
|
132
|
+
scale: 1.5,
|
|
133
|
+
glass: false,
|
|
134
|
+
refraction: 1,
|
|
135
|
+
dispersion: 1,
|
|
136
|
+
glassSize: 1
|
|
137
|
+
},
|
|
138
|
+
presentation() {
|
|
139
|
+
return { background: "#000000" };
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
|
|
94
143
|
{
|
|
95
144
|
id: "galaxy",
|
|
96
145
|
label: "Galaxy",
|
|
@@ -57,6 +57,7 @@ module.exports = deepFreeze({
|
|
|
57
57
|
settings: "/js/runtime/extensions/settings.js"
|
|
58
58
|
},
|
|
59
59
|
heroEffects: {
|
|
60
|
+
strands: "/js/runtime/hero-effects/strands.js",
|
|
60
61
|
ferrofluid: "/js/runtime/hero-effects/ferrofluid.js",
|
|
61
62
|
galaxy: "/js/runtime/hero-effects/galaxy.js",
|
|
62
63
|
lightRays: "/js/runtime/hero-effects/light-rays.js"
|
|
@@ -7,6 +7,7 @@ const { firstContentImage } = require("../seo");
|
|
|
7
7
|
const { caption } = require("../caption");
|
|
8
8
|
const { wikiReadmeHtml } = require("../wiki_readme");
|
|
9
9
|
const { filterShareServices } = require("../share-services");
|
|
10
|
+
const { COLLECTION_CONFIG_SCHEMA } = require("../../schema/content-config-schema");
|
|
10
11
|
const { projectHeroEffect } = require("../hero-effect-registry");
|
|
11
12
|
const { normalizeThemeComments } = require("../comments");
|
|
12
13
|
const { articleFooterDefaults, articlePresentationDefaults, collectionFooterDefaults, requireContentConfig } = require("../content-defaults");
|
|
@@ -14,6 +15,7 @@ const { cloneValue, deepFreeze, pick, mergeConfig, toContentNavigation, collecti
|
|
|
14
15
|
|
|
15
16
|
function heroBackground(value) {
|
|
16
17
|
const background = cloneValue(value || {});
|
|
18
|
+
background.parallax ??= COLLECTION_CONFIG_SCHEMA.properties.hero.properties.background.properties.parallax.default.value;
|
|
17
19
|
if (background.effect) background.effect = projectHeroEffect(background.effect);
|
|
18
20
|
return background;
|
|
19
21
|
}
|
package/scripts/lib/regions.js
CHANGED
|
@@ -61,7 +61,10 @@ function resolveRegion(layers, region, options = {}) {
|
|
|
61
61
|
result.brand = false;
|
|
62
62
|
result.menu = [];
|
|
63
63
|
}
|
|
64
|
-
if (region === "leftbar")
|
|
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
|
} : {})
|
|
@@ -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"] }],
|
|
@@ -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: [] })
|
|
@@ -253,6 +254,8 @@ function heroSchema(factory, options = {}) {
|
|
|
253
254
|
enabled: field("boolean", { example: true }),
|
|
254
255
|
background: object({
|
|
255
256
|
image: field("string", { example: "/hero.webp", consumers: options.pathConsumers }),
|
|
257
|
+
video: field("string", { example: "/hero.mp4", consumers: options.pathConsumers }),
|
|
258
|
+
parallax: field("number", { example: 0.2 }),
|
|
256
259
|
effect: object({
|
|
257
260
|
type: field("string", { example: "galaxy" }),
|
|
258
261
|
options: field("object", {
|
|
@@ -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
|
|
@@ -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
|
package/source/js/profile.js
CHANGED
|
@@ -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 =
|
|
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 {
|
|
151
|
+
} finally {
|
|
152
|
+
clearTimeout(timeout);
|
|
153
|
+
clearLoading();
|
|
154
|
+
}
|
|
138
155
|
}
|
|
139
156
|
|
|
140
157
|
document.addEventListener('click', event => {
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/* Adapted from React Bits Strands (MIT, Copyright (c) 2026 David Haz).
|
|
2
|
+
* See legal/THIRD-PARTY-NOTICES.md. Native WebGL2 renderer for Stellar. */
|
|
3
|
+
|
|
4
|
+
const VERT = `#version 300 es
|
|
5
|
+
in vec2 position;
|
|
6
|
+
void main() {
|
|
7
|
+
gl_Position = vec4(position, 0.0, 1.0);
|
|
8
|
+
}
|
|
9
|
+
`;
|
|
10
|
+
|
|
11
|
+
const FRAG = `#version 300 es
|
|
12
|
+
precision highp float;
|
|
13
|
+
|
|
14
|
+
uniform float uTime;
|
|
15
|
+
uniform vec2 uResolution;
|
|
16
|
+
uniform vec3 uColors[8];
|
|
17
|
+
uniform int uColorCount;
|
|
18
|
+
uniform int uStrandCount;
|
|
19
|
+
uniform float uSpeed;
|
|
20
|
+
uniform float uAmplitude;
|
|
21
|
+
uniform float uWaviness;
|
|
22
|
+
uniform float uThickness;
|
|
23
|
+
uniform float uGlow;
|
|
24
|
+
uniform float uTaper;
|
|
25
|
+
uniform float uSpread;
|
|
26
|
+
uniform float uHueShift;
|
|
27
|
+
uniform float uIntensity;
|
|
28
|
+
uniform float uOpacity;
|
|
29
|
+
uniform float uScale;
|
|
30
|
+
uniform float uSaturation;
|
|
31
|
+
|
|
32
|
+
out vec4 fragColor;
|
|
33
|
+
|
|
34
|
+
const float PI = 3.14159265;
|
|
35
|
+
|
|
36
|
+
vec3 spectrum(float t) {
|
|
37
|
+
return 0.5 + 0.5 * cos(2.0 * PI * (t + vec3(0.00, 0.33, 0.67)));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
vec3 samplePalette(float t) {
|
|
41
|
+
t = fract(t);
|
|
42
|
+
float scaled = t * float(uColorCount);
|
|
43
|
+
int idx = int(floor(scaled));
|
|
44
|
+
float blend = fract(scaled);
|
|
45
|
+
int nextIdx = idx + 1;
|
|
46
|
+
if (nextIdx >= uColorCount) nextIdx = 0;
|
|
47
|
+
return mix(uColors[idx], uColors[nextIdx], blend);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
vec3 strandColor(float t) {
|
|
51
|
+
if (uColorCount > 0) return samplePalette(t);
|
|
52
|
+
return spectrum(t);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
void main() {
|
|
56
|
+
vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / uResolution.y;
|
|
57
|
+
uv /= max(uScale, 0.0001);
|
|
58
|
+
|
|
59
|
+
float e = 0.06 + uIntensity * 0.94;
|
|
60
|
+
float env = pow(max(cos(uv.x * PI * 1.3), 0.0), uTaper);
|
|
61
|
+
|
|
62
|
+
vec3 col = vec3(0.0);
|
|
63
|
+
|
|
64
|
+
for (int i = 0; i < 12; i++) {
|
|
65
|
+
if (i >= uStrandCount) break;
|
|
66
|
+
|
|
67
|
+
float fi = float(i);
|
|
68
|
+
float ph = fi * 1.7 * uSpread;
|
|
69
|
+
float freq = (2.0 + fi * 0.35) * uWaviness;
|
|
70
|
+
float spd = 1.4 + fi * 1.2;
|
|
71
|
+
|
|
72
|
+
float tt = uTime * uSpeed;
|
|
73
|
+
float w = sin(uv.x * freq + tt * spd + ph) * 0.60
|
|
74
|
+
+ sin(uv.x * freq * 1.1 - tt * spd * 0.7 + ph * 1.7) * 0.40;
|
|
75
|
+
|
|
76
|
+
float amp = (0.1 + 0.02 * e) * env * uAmplitude;
|
|
77
|
+
float y = w * amp;
|
|
78
|
+
|
|
79
|
+
float d = abs(uv.y - y);
|
|
80
|
+
float thick = (0.001 + 0.05 * e) * (0.35 + env) * uThickness;
|
|
81
|
+
float g = thick / (d + thick * 0.45);
|
|
82
|
+
g = g * g;
|
|
83
|
+
|
|
84
|
+
float h = fi / float(uStrandCount) + uv.x * 0.30 + uTime * 0.04 + uHueShift;
|
|
85
|
+
col += strandColor(h) * g * env;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
col *= 0.45 + 0.7 * e;
|
|
89
|
+
col = 1.0 - exp(-col * uGlow);
|
|
90
|
+
|
|
91
|
+
float gray = dot(col, vec3(0.2126, 0.7152, 0.0722));
|
|
92
|
+
col = max(mix(vec3(gray), col, uSaturation), 0.0);
|
|
93
|
+
|
|
94
|
+
float lum = max(max(col.r, col.g), col.b);
|
|
95
|
+
float alpha = clamp(lum, 0.0, 1.0) * uOpacity;
|
|
96
|
+
|
|
97
|
+
fragColor = vec4(col * uOpacity, alpha);
|
|
98
|
+
}
|
|
99
|
+
`;
|
|
100
|
+
|
|
101
|
+
const GLASS_FRAG = `#version 300 es
|
|
102
|
+
precision highp float;
|
|
103
|
+
|
|
104
|
+
uniform sampler2D uScene;
|
|
105
|
+
uniform vec2 uResolution;
|
|
106
|
+
uniform float uRadius;
|
|
107
|
+
uniform float uRefraction;
|
|
108
|
+
uniform float uDispersion;
|
|
109
|
+
|
|
110
|
+
out vec4 fragColor;
|
|
111
|
+
|
|
112
|
+
vec2 toUv(vec2 p) {
|
|
113
|
+
return p * (uResolution.y / uResolution) + 0.5;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
void main() {
|
|
117
|
+
vec2 p = (gl_FragCoord.xy - 0.5 * uResolution) / uResolution.y;
|
|
118
|
+
float d = length(p);
|
|
119
|
+
float r = uRadius;
|
|
120
|
+
|
|
121
|
+
float edge = fwidth(d) * 1.5;
|
|
122
|
+
float mask = 1.0 - smoothstep(r - edge, r + edge, d);
|
|
123
|
+
if (mask <= 0.0) {
|
|
124
|
+
fragColor = vec4(0.0);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// sphere height: 0 at the rim, 1 at the center
|
|
129
|
+
float z = sqrt(max(r * r - d * d, 0.0)) / r;
|
|
130
|
+
float nd = d / r; // 0 at the center, 1 at the rim
|
|
131
|
+
|
|
132
|
+
// refraction is confined to a narrow band near the rim; the rest stays undistorted
|
|
133
|
+
vec2 dir = d > 0.0 ? p / d : vec2(0.0);
|
|
134
|
+
float lens = smoothstep(0.85, 1.0, nd) * pow(nd, 6.0);
|
|
135
|
+
vec2 offset = -dir * lens * uRefraction * 0.15;
|
|
136
|
+
vec2 disp = -dir * lens * uDispersion * 0.012;
|
|
137
|
+
|
|
138
|
+
vec3 light;
|
|
139
|
+
light.r = texture(uScene, toUv(p + offset - disp)).r;
|
|
140
|
+
light.g = texture(uScene, toUv(p + offset)).g;
|
|
141
|
+
light.b = texture(uScene, toUv(p + offset + disp)).b;
|
|
142
|
+
|
|
143
|
+
// neutral fresnel rim (no color tint so the glass stays clear)
|
|
144
|
+
float fres = pow(1.0 - z, 3.0);
|
|
145
|
+
vec3 rim = vec3(1.0) * fres * 0.18;
|
|
146
|
+
|
|
147
|
+
// specular highlight from the upper-left
|
|
148
|
+
vec2 lightDir = normalize(vec2(-0.55, 0.6));
|
|
149
|
+
float spec = pow(max(dot(p / max(r, 1e-4), lightDir), 0.0), 6.0);
|
|
150
|
+
spec *= (1.0 - smoothstep(r * 0.55, r, d));
|
|
151
|
+
|
|
152
|
+
vec3 emissive = light + rim + vec3(spec) * 0.4;
|
|
153
|
+
float emissiveA = clamp(max(max(emissive.r, emissive.g), emissive.b), 0.0, 1.0);
|
|
154
|
+
|
|
155
|
+
// almost clear glass body: only a faint neutral darkening, mostly near the rim
|
|
156
|
+
float bodyA = 0.05 + fres * 0.05;
|
|
157
|
+
|
|
158
|
+
// composite emissive light over the clear body (premultiplied)
|
|
159
|
+
float outA = emissiveA + bodyA * (1.0 - emissiveA);
|
|
160
|
+
vec3 outRGB = emissive;
|
|
161
|
+
|
|
162
|
+
outRGB *= mask;
|
|
163
|
+
outA *= mask;
|
|
164
|
+
|
|
165
|
+
fragColor = vec4(outRGB, outA);
|
|
166
|
+
}
|
|
167
|
+
`;
|
|
168
|
+
|
|
169
|
+
export function createRenderer(canvas, options, defaults) {
|
|
170
|
+
if (!canvas) return null;
|
|
171
|
+
let gl;
|
|
172
|
+
try {
|
|
173
|
+
gl = canvas.getContext('webgl2', { alpha: true, premultipliedAlpha: true, antialias: false });
|
|
174
|
+
} catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
if (!gl) return null;
|
|
178
|
+
const params = { ...defaults, ...options };
|
|
179
|
+
const programs = [];
|
|
180
|
+
const shaders = [];
|
|
181
|
+
let buffer;
|
|
182
|
+
let texture;
|
|
183
|
+
let framebuffer;
|
|
184
|
+
let destroyed = false;
|
|
185
|
+
|
|
186
|
+
function destroy() {
|
|
187
|
+
if (destroyed) return;
|
|
188
|
+
destroyed = true;
|
|
189
|
+
if (buffer) gl.deleteBuffer(buffer);
|
|
190
|
+
if (texture) gl.deleteTexture(texture);
|
|
191
|
+
if (framebuffer) gl.deleteFramebuffer(framebuffer);
|
|
192
|
+
programs.forEach(program => gl.deleteProgram(program));
|
|
193
|
+
shaders.forEach(shader => gl.deleteShader(shader));
|
|
194
|
+
gl.getExtension('WEBGL_lose_context')?.loseContext();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function programFor(fragment) {
|
|
198
|
+
const program = gl.createProgram();
|
|
199
|
+
programs.push(program);
|
|
200
|
+
for (const [type, source] of [[gl.VERTEX_SHADER, VERT], [gl.FRAGMENT_SHADER, fragment]]) {
|
|
201
|
+
const shader = gl.createShader(type);
|
|
202
|
+
shaders.push(shader);
|
|
203
|
+
gl.shaderSource(shader, source);
|
|
204
|
+
gl.compileShader(shader);
|
|
205
|
+
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(shader));
|
|
206
|
+
gl.attachShader(program, shader);
|
|
207
|
+
}
|
|
208
|
+
gl.linkProgram(program);
|
|
209
|
+
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(program));
|
|
210
|
+
return program;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
const scene = programFor(FRAG);
|
|
215
|
+
const glass = params.glass ? programFor(GLASS_FRAG) : null;
|
|
216
|
+
buffer = gl.createBuffer();
|
|
217
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
|
218
|
+
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
|
|
219
|
+
function use(program) {
|
|
220
|
+
gl.useProgram(program);
|
|
221
|
+
const position = gl.getAttribLocation(program, 'position');
|
|
222
|
+
gl.enableVertexAttribArray(position);
|
|
223
|
+
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
|
|
224
|
+
}
|
|
225
|
+
function scalar(program, name, value) {
|
|
226
|
+
gl.uniform1f(gl.getUniformLocation(program, name), value);
|
|
227
|
+
}
|
|
228
|
+
use(scene);
|
|
229
|
+
const palette = params.colors.slice(0, 8);
|
|
230
|
+
const colors = Array.from({ length: 8 }, (_, index) => {
|
|
231
|
+
const hex = (palette[index] || palette[palette.length - 1] || '#ffffff').replace(/^#/, '');
|
|
232
|
+
return [0, 2, 4].map(offset => parseInt(hex.slice(offset, offset + 2), 16) / 255);
|
|
233
|
+
}).flat();
|
|
234
|
+
gl.uniform3fv(gl.getUniformLocation(scene, 'uColors[0]'), colors);
|
|
235
|
+
gl.uniform1i(gl.getUniformLocation(scene, 'uColorCount'), palette.length);
|
|
236
|
+
gl.uniform1i(gl.getUniformLocation(scene, 'uStrandCount'), Math.min(12, Math.max(1, Math.round(params.count))));
|
|
237
|
+
for (const name of ['speed', 'amplitude', 'waviness', 'thickness', 'glow', 'taper', 'spread', 'hueShift', 'intensity', 'opacity', 'scale', 'saturation']) {
|
|
238
|
+
let value = params[name];
|
|
239
|
+
if (name !== 'speed' && name !== 'hueShift') value = Math.max(0, value);
|
|
240
|
+
if (name === 'thickness' || name === 'scale') value = Math.max(0.0001, value);
|
|
241
|
+
if (name === 'opacity') value = Math.min(1, value);
|
|
242
|
+
scalar(scene, `u${name[0].toUpperCase()}${name.slice(1)}`, value);
|
|
243
|
+
}
|
|
244
|
+
const timeLocation = gl.getUniformLocation(scene, 'uTime');
|
|
245
|
+
if (glass) {
|
|
246
|
+
use(glass);
|
|
247
|
+
scalar(glass, 'uRadius', Math.max(0.0001, 0.46 * params.glassSize));
|
|
248
|
+
scalar(glass, 'uRefraction', params.refraction);
|
|
249
|
+
scalar(glass, 'uDispersion', params.dispersion);
|
|
250
|
+
gl.uniform1i(gl.getUniformLocation(glass, 'uScene'), 0);
|
|
251
|
+
texture = gl.createTexture();
|
|
252
|
+
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
253
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
254
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
255
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
256
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
257
|
+
framebuffer = gl.createFramebuffer();
|
|
258
|
+
}
|
|
259
|
+
let width = 0;
|
|
260
|
+
let height = 0;
|
|
261
|
+
return Object.freeze({
|
|
262
|
+
pointer: false,
|
|
263
|
+
resize(rect, devicePixelRatio) {
|
|
264
|
+
const dpr = Math.min(devicePixelRatio || 1, 2);
|
|
265
|
+
const nextWidth = Math.max(1, Math.round(rect.width * dpr));
|
|
266
|
+
const nextHeight = Math.max(1, Math.round(rect.height * dpr));
|
|
267
|
+
if (width === nextWidth && height === nextHeight) return;
|
|
268
|
+
width = canvas.width = nextWidth;
|
|
269
|
+
height = canvas.height = nextHeight;
|
|
270
|
+
gl.viewport(0, 0, width, height);
|
|
271
|
+
for (const program of [scene, glass].filter(Boolean)) {
|
|
272
|
+
use(program);
|
|
273
|
+
gl.uniform2f(gl.getUniformLocation(program, 'uResolution'), width, height);
|
|
274
|
+
}
|
|
275
|
+
if (glass) {
|
|
276
|
+
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
277
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
|
|
278
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
|
|
279
|
+
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
|
|
280
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
render(time) {
|
|
284
|
+
if (destroyed) return;
|
|
285
|
+
use(scene);
|
|
286
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, glass ? framebuffer : null);
|
|
287
|
+
gl.uniform1f(timeLocation, time * 0.001);
|
|
288
|
+
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
|
289
|
+
if (glass) {
|
|
290
|
+
use(glass);
|
|
291
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
292
|
+
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
293
|
+
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
destroy
|
|
297
|
+
});
|
|
298
|
+
} catch {
|
|
299
|
+
destroy();
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
}
|