bezier-slider 1.0.2 → 1.0.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/README.md +0 -11
- package/demo/index.html +17 -9
- package/demo/native-main.js +92 -9
- package/demo/shared/code-highlight.js +41 -0
- package/demo/shared/constants.js +5 -0
- package/demo/shared/demo-native.css +120 -1
- package/demo/shared/demo.css +124 -6
- package/demo/shared/geometry-preset-bar.js +67 -16
- package/demo/shared/geometry-presets.js +2 -2
- package/demo/shared/params-panel.js +112 -7
- package/demo/shared/saved-presets.js +66 -0
- package/demo/shared/track-helpers.js +92 -6
- package/dist/bezier-slider.mjs +12 -15
- package/package.json +5 -4
- package/src/bezier-slider.native.js +15 -16
package/README.md
CHANGED
|
@@ -618,14 +618,3 @@ console.log(BezierSlider.DEFAULTS);
|
|
|
618
618
|
}
|
|
619
619
|
```
|
|
620
620
|
|
|
621
|
-
---
|
|
622
|
-
|
|
623
|
-
## 版本记录
|
|
624
|
-
|
|
625
|
-
| 版本 | 日期 | 说明 |
|
|
626
|
-
|------|------|------|
|
|
627
|
-
| v3.0 | 2026-06-10 | 支持 Vue 3;添加 `fadeEnabled` 参数;项目结构重构 |
|
|
628
|
-
| v2.2 | 2026-06-09 | 支持图片/SVG/font-icon 多种图标;边界拉扯回弹 |
|
|
629
|
-
| v2.1 | 2026-06-09 | 抽象公共组件;中文注释;动态布局 |
|
|
630
|
-
| v2.0 | 2026-06-09 | 二次贝塞尔滑轨;弧度/间距/回调可配置 |
|
|
631
|
-
| v1.0 | 2026-06-09 | 初始圆弧版本(已废弃) |
|
package/demo/index.html
CHANGED
|
@@ -18,13 +18,21 @@
|
|
|
18
18
|
<aside class="params-panel" id="paramsPanel">
|
|
19
19
|
<div class="params-panel-header">
|
|
20
20
|
<h2>调节参数</h2>
|
|
21
|
-
<
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
21
|
+
<div class="params-panel-actions">
|
|
22
|
+
<button
|
|
23
|
+
type="button"
|
|
24
|
+
class="params-save"
|
|
25
|
+
id="paramsSave"
|
|
26
|
+
data-tooltip="保存到快捷预设"
|
|
27
|
+
>保存</button>
|
|
28
|
+
<button
|
|
29
|
+
type="button"
|
|
30
|
+
class="params-reset"
|
|
31
|
+
id="paramsReset"
|
|
32
|
+
aria-label="恢复默认"
|
|
33
|
+
data-tooltip="恢复默认"
|
|
34
|
+
></button>
|
|
35
|
+
</div>
|
|
28
36
|
</div>
|
|
29
37
|
<div id="paramsForm"></div>
|
|
30
38
|
</aside>
|
|
@@ -41,7 +49,7 @@
|
|
|
41
49
|
</div>
|
|
42
50
|
<label class="demo-options" id="debugOption">
|
|
43
51
|
<input type="checkbox" id="showDebugTrack" checked />
|
|
44
|
-
|
|
52
|
+
显示轨迹调试线(含 P0–P2 控制点)
|
|
45
53
|
</label>
|
|
46
54
|
<div class="slider-compose" id="sliderCompose">
|
|
47
55
|
<div class="carousel-bg" id="carouselBg"></div>
|
|
@@ -73,7 +81,7 @@
|
|
|
73
81
|
<button type="button" data-code="react">React</button>
|
|
74
82
|
<button type="button" data-code="vue">Vue</button>
|
|
75
83
|
</div>
|
|
76
|
-
<pre class="code-content" id="codeContent"></pre>
|
|
84
|
+
<pre class="code-content" id="codeContent"><code></code></pre>
|
|
77
85
|
</aside>
|
|
78
86
|
</div>
|
|
79
87
|
|
package/demo/native-main.js
CHANGED
|
@@ -9,8 +9,14 @@ import {
|
|
|
9
9
|
} from './shared/param-utils.js';
|
|
10
10
|
import { bindParamsPanel } from './shared/params-panel.js';
|
|
11
11
|
import { bindGeometryPresetBar } from './shared/geometry-preset-bar.js';
|
|
12
|
+
import {
|
|
13
|
+
buildPresetSnapshot,
|
|
14
|
+
removeSavedPreset,
|
|
15
|
+
savePresetSnapshot
|
|
16
|
+
} from './shared/saved-presets.js';
|
|
12
17
|
import { bindCopyButton, bindResetButton } from './shared/clipboard.js';
|
|
13
18
|
import { CODE_FORMATTERS } from './shared/format-code.js';
|
|
19
|
+
import { getCodeTabLanguage, getPlainCodeText, renderHighlightedCode } from './shared/code-highlight.js';
|
|
14
20
|
import {
|
|
15
21
|
applyComposeLayout,
|
|
16
22
|
fitImageDisplaySize,
|
|
@@ -48,6 +54,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
48
54
|
const codeContent = document.getElementById('codeContent');
|
|
49
55
|
const codeCopyBtn = document.getElementById('codeCopyBtn');
|
|
50
56
|
const paramsReset = document.getElementById('paramsReset');
|
|
57
|
+
const paramsSave = document.getElementById('paramsSave');
|
|
51
58
|
const geometryPresetBar = document.getElementById('geometryPresetBar');
|
|
52
59
|
|
|
53
60
|
let slider = null;
|
|
@@ -57,6 +64,29 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
57
64
|
let customBgUrl = null;
|
|
58
65
|
let bgNaturalSize = { ...DEFAULT_BG_NATURAL };
|
|
59
66
|
let displaySize = fitImageDisplaySize(bgNaturalSize.width, bgNaturalSize.height);
|
|
67
|
+
let activeControl = null;
|
|
68
|
+
|
|
69
|
+
function refreshDebugOverlay() {
|
|
70
|
+
if (!slider) return;
|
|
71
|
+
updateDebugTrack(
|
|
72
|
+
sliderMount,
|
|
73
|
+
slider.getLayoutState(),
|
|
74
|
+
showDebugTrack.checked,
|
|
75
|
+
BezierSlider,
|
|
76
|
+
activeControl
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function setActiveControl(hint) {
|
|
81
|
+
activeControl = hint;
|
|
82
|
+
refreshDebugOverlay();
|
|
83
|
+
paramsForm.querySelectorAll('.param-row-cp').forEach((row) => {
|
|
84
|
+
const match = activeControl
|
|
85
|
+
&& row.dataset.controlPoint === activeControl.point
|
|
86
|
+
&& row.dataset.controlAxis === activeControl.axis;
|
|
87
|
+
row.classList.toggle('is-adjusting', Boolean(match));
|
|
88
|
+
});
|
|
89
|
+
}
|
|
60
90
|
|
|
61
91
|
function getActiveBgUrl() {
|
|
62
92
|
return customBgUrl;
|
|
@@ -85,7 +115,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
85
115
|
|
|
86
116
|
function updateParamsPreview() {
|
|
87
117
|
const formatter = CODE_FORMATTERS[currentCodeTab] ?? CODE_FORMATTERS.native;
|
|
88
|
-
|
|
118
|
+
const source = formatter(params, getCodePreviewOptions());
|
|
119
|
+
renderHighlightedCode(codeContent, source, getCodeTabLanguage(currentCodeTab));
|
|
89
120
|
legendCenterT.textContent = `centerT = ${params.centerT}`;
|
|
90
121
|
}
|
|
91
122
|
|
|
@@ -144,7 +175,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
144
175
|
},
|
|
145
176
|
onSlideEnd: (index) => console.log('停留下标:', index),
|
|
146
177
|
onLayout: (layout) => {
|
|
147
|
-
updateDebugTrack(sliderMount, layout, showDebugTrack.checked, BezierSlider);
|
|
178
|
+
updateDebugTrack(sliderMount, layout, showDebugTrack.checked, BezierSlider, activeControl);
|
|
148
179
|
}
|
|
149
180
|
});
|
|
150
181
|
|
|
@@ -190,16 +221,70 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
190
221
|
scheduleRebuild();
|
|
191
222
|
}
|
|
192
223
|
|
|
224
|
+
async function applySavedPreset(preset) {
|
|
225
|
+
params = deepClone(preset.params);
|
|
226
|
+
syncParamsForm(paramsForm, params, PARAM_SCHEMA);
|
|
227
|
+
|
|
228
|
+
if (preset.bgUrl) {
|
|
229
|
+
bgNaturalSize = { ...preset.bgNaturalSize };
|
|
230
|
+
await applyBackground(preset.bgUrl, preset.bgFileName || '已存背景');
|
|
231
|
+
} else {
|
|
232
|
+
bgNaturalSize = preset.bgNaturalSize
|
|
233
|
+
? { ...preset.bgNaturalSize }
|
|
234
|
+
: { ...DEFAULT_BG_NATURAL };
|
|
235
|
+
await applyBackground(null);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
updateParamsPreview();
|
|
239
|
+
createSlider(true);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function handleDeleteSavedPreset(preset) {
|
|
243
|
+
removeSavedPreset(preset.id);
|
|
244
|
+
geometryBarApi.refresh();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function handleSavePreset() {
|
|
248
|
+
const defaultLabel = customBgUrl
|
|
249
|
+
? (bgFileName.textContent || '我的配置')
|
|
250
|
+
: `配置 ${new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
|
|
251
|
+
const label = window.prompt('为当前配置命名', defaultLabel);
|
|
252
|
+
if (label == null || !label.trim()) return;
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
const snapshot = buildPresetSnapshot({
|
|
256
|
+
label: label.trim(),
|
|
257
|
+
params,
|
|
258
|
+
bgUrl: customBgUrl,
|
|
259
|
+
bgFileName: bgFileName.textContent,
|
|
260
|
+
bgNaturalSize
|
|
261
|
+
});
|
|
262
|
+
savePresetSnapshot(snapshot);
|
|
263
|
+
geometryBarApi.refresh();
|
|
264
|
+
|
|
265
|
+
if (snapshot.bgOmitted && customBgUrl) {
|
|
266
|
+
window.alert('配置已保存。背景图过大未写入本地,加载该预设后请重新上传背景图。');
|
|
267
|
+
}
|
|
268
|
+
} catch (err) {
|
|
269
|
+
window.alert(err.message || '保存失败');
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
193
273
|
const unbindPanel = bindParamsPanel(paramsForm, {
|
|
194
274
|
getParams: () => params,
|
|
195
275
|
onParamChange: handleParamChange,
|
|
276
|
+
onControlPointAdjustStart: setActiveControl,
|
|
277
|
+
onControlPointAdjustEnd: () => setActiveControl(null),
|
|
196
278
|
schema: PARAM_SCHEMA
|
|
197
279
|
});
|
|
198
|
-
const
|
|
199
|
-
onPreset: handleGeometryPreset
|
|
280
|
+
const geometryBarApi = bindGeometryPresetBar(geometryPresetBar, {
|
|
281
|
+
onPreset: handleGeometryPreset,
|
|
282
|
+
onSavedPreset: applySavedPreset,
|
|
283
|
+
onDeleteSaved: handleDeleteSavedPreset
|
|
200
284
|
});
|
|
201
285
|
const unbindReset = bindResetButton(paramsReset, handleReset);
|
|
202
|
-
|
|
286
|
+
paramsSave?.addEventListener('click', handleSavePreset);
|
|
287
|
+
const unbindCopy = bindCopyButton(codeCopyBtn, () => getPlainCodeText(codeContent));
|
|
203
288
|
|
|
204
289
|
applyDisplayLayout();
|
|
205
290
|
applyBgLayer(carouselBg, null);
|
|
@@ -235,9 +320,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
235
320
|
});
|
|
236
321
|
|
|
237
322
|
showDebugTrack.addEventListener('change', () => {
|
|
238
|
-
|
|
239
|
-
updateDebugTrack(sliderMount, slider.getLayoutState(), showDebugTrack.checked, BezierSlider);
|
|
240
|
-
}
|
|
323
|
+
refreshDebugOverlay();
|
|
241
324
|
});
|
|
242
325
|
|
|
243
326
|
window.addEventListener('resize', () => {
|
|
@@ -249,7 +332,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
249
332
|
|
|
250
333
|
window.addEventListener('beforeunload', () => {
|
|
251
334
|
unbindPanel();
|
|
252
|
-
|
|
335
|
+
geometryBarApi.destroy();
|
|
253
336
|
unbindReset();
|
|
254
337
|
unbindCopy();
|
|
255
338
|
slider?.destroy();
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import hljs from 'highlight.js/lib/common';
|
|
2
|
+
import 'highlight.js/styles/atom-one-dark.min.css';
|
|
3
|
+
|
|
4
|
+
const CODE_TAB_LANGUAGES = {
|
|
5
|
+
native: 'html',
|
|
6
|
+
react: 'javascript',
|
|
7
|
+
vue: 'xml'
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
function ensureCodeElement(container) {
|
|
11
|
+
let codeEl = container.querySelector('code');
|
|
12
|
+
if (!codeEl) {
|
|
13
|
+
container.textContent = '';
|
|
14
|
+
codeEl = document.createElement('code');
|
|
15
|
+
container.appendChild(codeEl);
|
|
16
|
+
}
|
|
17
|
+
return codeEl;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getCodeTabLanguage(tab) {
|
|
21
|
+
return CODE_TAB_LANGUAGES[tab] ?? 'javascript';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function renderHighlightedCode(container, source, language) {
|
|
25
|
+
const codeEl = ensureCodeElement(container);
|
|
26
|
+
const lang = hljs.getLanguage(language) ? language : 'javascript';
|
|
27
|
+
|
|
28
|
+
codeEl.removeAttribute('data-highlighted');
|
|
29
|
+
codeEl.className = `hljs language-${lang}`;
|
|
30
|
+
codeEl.textContent = source;
|
|
31
|
+
|
|
32
|
+
if (source.trim()) {
|
|
33
|
+
hljs.highlightElement(codeEl);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return source;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getPlainCodeText(container) {
|
|
40
|
+
return container.querySelector('code')?.textContent ?? container.textContent ?? '';
|
|
41
|
+
}
|
package/demo/shared/constants.js
CHANGED
|
@@ -42,3 +42,8 @@ export const COPY_ICON_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="curre
|
|
|
42
42
|
export const COPIED_ICON_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
43
43
|
<polyline points="20 6 9 17 4 12"/>
|
|
44
44
|
</svg>`;
|
|
45
|
+
|
|
46
|
+
export const DELETE_ICON_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
|
|
47
|
+
<path d="M6 6l12 12"/>
|
|
48
|
+
<path d="M18 6L6 18"/>
|
|
49
|
+
</svg>`;
|
|
@@ -80,6 +80,58 @@
|
|
|
80
80
|
height: 100%;
|
|
81
81
|
pointer-events: none;
|
|
82
82
|
z-index: 2;
|
|
83
|
+
overflow: visible;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.debug-track .debug-point-label,
|
|
87
|
+
.debug-track .debug-axis-label,
|
|
88
|
+
.debug-track .debug-point-hint {
|
|
89
|
+
pointer-events: none;
|
|
90
|
+
user-select: none;
|
|
91
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.debug-track .debug-point-hint {
|
|
95
|
+
font-size: 11px;
|
|
96
|
+
font-weight: 600;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.debug-track .debug-axis-label {
|
|
100
|
+
font-size: 11px;
|
|
101
|
+
font-weight: 600;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.debug-track .debug-point-pulse {
|
|
105
|
+
animation: debug-point-pulse 1.2s ease-in-out infinite;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
@keyframes debug-point-pulse {
|
|
109
|
+
0%, 100% { opacity: 0.3; transform: scale(1); transform-origin: center; transform-box: fill-box; }
|
|
110
|
+
50% { opacity: 0.75; transform: scale(1.25); transform-origin: center; transform-box: fill-box; }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.param-row-cp {
|
|
114
|
+
position: relative;
|
|
115
|
+
z-index: 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.param-row-cp::before {
|
|
119
|
+
content: '';
|
|
120
|
+
position: absolute;
|
|
121
|
+
inset: -2px -6px;
|
|
122
|
+
border-radius: 8px;
|
|
123
|
+
background: transparent;
|
|
124
|
+
pointer-events: none;
|
|
125
|
+
z-index: -1;
|
|
126
|
+
transition: background 0.15s ease;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.param-row-cp.is-adjusting::before {
|
|
130
|
+
background: rgba(252, 211, 77, 0.08);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.param-row-cp.is-adjusting label {
|
|
134
|
+
color: #fcd34d;
|
|
83
135
|
}
|
|
84
136
|
|
|
85
137
|
.legend {
|
|
@@ -111,12 +163,79 @@
|
|
|
111
163
|
display: flex;
|
|
112
164
|
flex-wrap: wrap;
|
|
113
165
|
justify-content: center;
|
|
166
|
+
align-items: center;
|
|
114
167
|
gap: 8px;
|
|
115
|
-
max-width: min(calc(100vw - 32px),
|
|
168
|
+
max-width: min(calc(100vw - 32px), 900px);
|
|
116
169
|
transform: translateX(-50%);
|
|
117
170
|
pointer-events: none;
|
|
118
171
|
}
|
|
119
172
|
|
|
173
|
+
.geometry-preset-divider {
|
|
174
|
+
width: 1px;
|
|
175
|
+
height: 22px;
|
|
176
|
+
background: rgba(255, 255, 255, 0.2);
|
|
177
|
+
pointer-events: none;
|
|
178
|
+
flex-shrink: 0;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.geometry-preset-bar .geometry-preset-btn-saved {
|
|
182
|
+
background: rgba(59, 130, 246, 0.35);
|
|
183
|
+
border-color: rgba(147, 197, 253, 0.45);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.geometry-preset-bar .geometry-preset-btn-saved:hover {
|
|
187
|
+
background: rgba(59, 130, 246, 0.5);
|
|
188
|
+
border-color: rgba(191, 219, 254, 0.65);
|
|
189
|
+
color: #fff;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.geometry-preset-saved-item {
|
|
193
|
+
position: relative;
|
|
194
|
+
pointer-events: auto;
|
|
195
|
+
flex-shrink: 0;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.geometry-preset-delete {
|
|
199
|
+
position: absolute;
|
|
200
|
+
top: -5px;
|
|
201
|
+
right: -5px;
|
|
202
|
+
z-index: 2;
|
|
203
|
+
display: flex;
|
|
204
|
+
align-items: center;
|
|
205
|
+
justify-content: center;
|
|
206
|
+
width: 16px;
|
|
207
|
+
height: 16px;
|
|
208
|
+
padding: 0;
|
|
209
|
+
border: 1px solid rgba(255, 255, 255, 0.35);
|
|
210
|
+
border-radius: 50%;
|
|
211
|
+
background: rgba(15, 23, 42, 0.92);
|
|
212
|
+
color: rgba(255, 255, 255, 0.9);
|
|
213
|
+
cursor: pointer;
|
|
214
|
+
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
|
|
215
|
+
opacity: 0;
|
|
216
|
+
pointer-events: none;
|
|
217
|
+
transition: opacity 0.15s, background 0.15s, color 0.15s, border-color 0.15s, transform 0.15s;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.geometry-preset-saved-item:hover .geometry-preset-delete,
|
|
221
|
+
.geometry-preset-saved-item:focus-within .geometry-preset-delete {
|
|
222
|
+
opacity: 1;
|
|
223
|
+
pointer-events: auto;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
.geometry-preset-delete svg {
|
|
227
|
+
width: 9px;
|
|
228
|
+
height: 9px;
|
|
229
|
+
display: block;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.geometry-preset-delete:hover {
|
|
233
|
+
background: rgba(239, 68, 68, 0.95);
|
|
234
|
+
border-color: rgba(254, 202, 202, 0.85);
|
|
235
|
+
color: #fff;
|
|
236
|
+
transform: scale(1.08);
|
|
237
|
+
}
|
|
238
|
+
|
|
120
239
|
.geometry-preset-bar .geometry-preset-btn {
|
|
121
240
|
pointer-events: auto;
|
|
122
241
|
padding: 7px 14px;
|
package/demo/shared/demo.css
CHANGED
|
@@ -99,9 +99,14 @@ body {
|
|
|
99
99
|
z-index: 20;
|
|
100
100
|
backdrop-filter: blur(8px);
|
|
101
101
|
flex-shrink: 0;
|
|
102
|
+
box-sizing: border-box;
|
|
103
|
+
min-width: 200px;
|
|
104
|
+
width: 20%;
|
|
105
|
+
max-width: 20%;
|
|
106
|
+
overflow-y: auto;
|
|
102
107
|
}
|
|
103
108
|
|
|
104
|
-
.params-panel {
|
|
109
|
+
/* .params-panel {
|
|
105
110
|
width: 260px;
|
|
106
111
|
overflow-y: auto;
|
|
107
112
|
}
|
|
@@ -109,13 +114,14 @@ body {
|
|
|
109
114
|
.code-panel {
|
|
110
115
|
width: 320px;
|
|
111
116
|
overflow-y: auto;
|
|
112
|
-
}
|
|
117
|
+
} */
|
|
113
118
|
|
|
114
119
|
body.demo-native .params-panel {
|
|
115
120
|
position: absolute;
|
|
116
121
|
top: 20px;
|
|
117
122
|
left: 20px;
|
|
118
123
|
height: calc(100vh - 80px);
|
|
124
|
+
scrollbar-gutter: stable;
|
|
119
125
|
}
|
|
120
126
|
|
|
121
127
|
body.demo-native .code-panel {
|
|
@@ -134,6 +140,61 @@ body.demo-native .code-panel {
|
|
|
134
140
|
margin-bottom: 12px;
|
|
135
141
|
}
|
|
136
142
|
|
|
143
|
+
.params-panel-actions {
|
|
144
|
+
display: flex;
|
|
145
|
+
align-items: center;
|
|
146
|
+
gap: 6px;
|
|
147
|
+
flex-shrink: 0;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.params-save {
|
|
151
|
+
position: relative;
|
|
152
|
+
flex-shrink: 0;
|
|
153
|
+
padding: 4px 10px;
|
|
154
|
+
width: 46px;
|
|
155
|
+
height: 28px;
|
|
156
|
+
display: flex;
|
|
157
|
+
align-items: center;
|
|
158
|
+
justify-content: center;
|
|
159
|
+
border: 1px solid rgba(252, 211, 77, 0.35);
|
|
160
|
+
border-radius: 6px;
|
|
161
|
+
background: rgba(252, 211, 77, 0.12);
|
|
162
|
+
color: #fcd34d;
|
|
163
|
+
font-size: 11px;
|
|
164
|
+
/* line-height: 1.2; */
|
|
165
|
+
cursor: pointer;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.params-save:hover {
|
|
169
|
+
background: rgba(252, 211, 77, 0.22);
|
|
170
|
+
border-color: rgba(252, 211, 77, 0.5);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.params-save::after {
|
|
174
|
+
content: attr(data-tooltip);
|
|
175
|
+
position: absolute;
|
|
176
|
+
top: calc(100% + 6px);
|
|
177
|
+
right: 0;
|
|
178
|
+
padding: 4px 8px;
|
|
179
|
+
border-radius: 4px;
|
|
180
|
+
background: rgba(0, 0, 0, 0.85);
|
|
181
|
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
182
|
+
color: rgba(255, 255, 255, 0.9);
|
|
183
|
+
font-size: 11px;
|
|
184
|
+
white-space: nowrap;
|
|
185
|
+
pointer-events: none;
|
|
186
|
+
opacity: 0;
|
|
187
|
+
transform: translateY(-2px);
|
|
188
|
+
transition: opacity 0.15s, transform 0.15s;
|
|
189
|
+
z-index: 1;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.params-save:hover::after,
|
|
193
|
+
.params-save:focus-visible::after {
|
|
194
|
+
opacity: 1;
|
|
195
|
+
transform: translateY(0);
|
|
196
|
+
}
|
|
197
|
+
|
|
137
198
|
.params-panel-header h2,
|
|
138
199
|
.code-panel-header h2 {
|
|
139
200
|
margin: 0;
|
|
@@ -236,10 +297,12 @@ body.demo-native .code-panel {
|
|
|
236
297
|
|
|
237
298
|
.param-row {
|
|
238
299
|
display: grid;
|
|
239
|
-
grid-template-columns: 100px 1fr
|
|
300
|
+
grid-template-columns: 100px 1fr 44px;
|
|
240
301
|
gap: 6px;
|
|
241
302
|
align-items: center;
|
|
242
303
|
margin-bottom: 6px;
|
|
304
|
+
min-height: 24px;
|
|
305
|
+
width: 100%;
|
|
243
306
|
}
|
|
244
307
|
|
|
245
308
|
.param-row label {
|
|
@@ -247,8 +310,44 @@ body.demo-native .code-panel {
|
|
|
247
310
|
color: rgba(255, 255, 255, 0.72);
|
|
248
311
|
}
|
|
249
312
|
|
|
313
|
+
.param-stepper {
|
|
314
|
+
display: grid;
|
|
315
|
+
grid-template-columns: 24px 1fr 24px;
|
|
316
|
+
gap: 4px;
|
|
317
|
+
align-items: center;
|
|
318
|
+
min-width: 0;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
.param-step-btn {
|
|
322
|
+
width: 24px;
|
|
323
|
+
height: 24px;
|
|
324
|
+
padding: 0;
|
|
325
|
+
border: 1px solid rgba(255, 255, 255, 0.18);
|
|
326
|
+
border-radius: 6px;
|
|
327
|
+
background: rgba(255, 255, 255, 0.06);
|
|
328
|
+
color: rgba(255, 255, 255, 0.85);
|
|
329
|
+
font-size: 15px;
|
|
330
|
+
line-height: 1;
|
|
331
|
+
cursor: pointer;
|
|
332
|
+
flex-shrink: 0;
|
|
333
|
+
touch-action: manipulation;
|
|
334
|
+
user-select: none;
|
|
335
|
+
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
.param-step-btn:hover {
|
|
339
|
+
background: rgba(252, 211, 77, 0.15);
|
|
340
|
+
border-color: rgba(252, 211, 77, 0.35);
|
|
341
|
+
color: #fcd34d;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
.param-step-btn:active {
|
|
345
|
+
background: rgba(252, 211, 77, 0.25);
|
|
346
|
+
}
|
|
347
|
+
|
|
250
348
|
.param-row input[type="range"] {
|
|
251
349
|
width: 100%;
|
|
350
|
+
min-width: 0;
|
|
252
351
|
accent-color: #fcd34d;
|
|
253
352
|
}
|
|
254
353
|
|
|
@@ -257,6 +356,12 @@ body.demo-native .code-panel {
|
|
|
257
356
|
color: #fcd34d;
|
|
258
357
|
text-align: right;
|
|
259
358
|
font-variant-numeric: tabular-nums;
|
|
359
|
+
width: 44px;
|
|
360
|
+
min-height: 1.5em;
|
|
361
|
+
line-height: 1.5;
|
|
362
|
+
white-space: nowrap;
|
|
363
|
+
overflow: hidden;
|
|
364
|
+
text-overflow: ellipsis;
|
|
260
365
|
}
|
|
261
366
|
|
|
262
367
|
.param-check {
|
|
@@ -328,16 +433,28 @@ body.demo-native .code-copy-btn:focus-visible::after {
|
|
|
328
433
|
}
|
|
329
434
|
|
|
330
435
|
.code-content {
|
|
331
|
-
padding:
|
|
436
|
+
padding: 0;
|
|
332
437
|
background: rgba(0, 0, 0, 0.35);
|
|
333
438
|
border-radius: 8px;
|
|
334
439
|
font-family: ui-monospace, Consolas, monospace;
|
|
335
440
|
font-size: 11px;
|
|
336
441
|
line-height: 1.5;
|
|
337
|
-
color: rgba(255, 255, 255, 0.8);
|
|
338
442
|
white-space: pre-wrap;
|
|
339
443
|
word-break: break-all;
|
|
340
444
|
overflow-y: auto;
|
|
445
|
+
margin: 0;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
.code-content code.hljs {
|
|
449
|
+
display: block;
|
|
450
|
+
padding: 12px;
|
|
451
|
+
background: transparent;
|
|
452
|
+
overflow: visible;
|
|
453
|
+
white-space: pre-wrap;
|
|
454
|
+
word-break: break-all;
|
|
455
|
+
font-family: inherit;
|
|
456
|
+
font-size: inherit;
|
|
457
|
+
line-height: inherit;
|
|
341
458
|
}
|
|
342
459
|
|
|
343
460
|
body.demo-native .code-content {
|
|
@@ -376,7 +493,8 @@ body.demo-native .code-content {
|
|
|
376
493
|
.params-panel,
|
|
377
494
|
.code-panel {
|
|
378
495
|
width: 100%;
|
|
379
|
-
|
|
496
|
+
min-width: 200px;
|
|
497
|
+
max-width: 100%;
|
|
380
498
|
position: static;
|
|
381
499
|
max-height: none;
|
|
382
500
|
}
|
|
@@ -1,25 +1,76 @@
|
|
|
1
1
|
import { GEOMETRY_PRESETS } from './geometry-presets.js';
|
|
2
|
-
|
|
2
|
+
import { DELETE_ICON_SVG } from './constants.js';
|
|
3
|
+
import { loadSavedPresets } from './saved-presets.js';
|
|
3
4
|
export function bindGeometryPresetBar(container, {
|
|
4
5
|
presets = GEOMETRY_PRESETS,
|
|
5
|
-
|
|
6
|
+
getSavedPresets = loadSavedPresets,
|
|
7
|
+
onPreset,
|
|
8
|
+
onSavedPreset,
|
|
9
|
+
onDeleteSaved
|
|
6
10
|
} = {}) {
|
|
7
|
-
if (!container
|
|
8
|
-
return ()
|
|
11
|
+
if (!container) {
|
|
12
|
+
return { refresh() {}, destroy() {} };
|
|
9
13
|
}
|
|
10
14
|
|
|
11
|
-
|
|
12
|
-
presets.forEach((preset) => {
|
|
13
|
-
const btn = document.createElement('button');
|
|
14
|
-
btn.type = 'button';
|
|
15
|
-
btn.className = 'geometry-preset-btn';
|
|
16
|
-
btn.dataset.preset = preset.id;
|
|
17
|
-
btn.textContent = preset.label;
|
|
18
|
-
btn.addEventListener('click', () => onPreset(preset));
|
|
19
|
-
container.appendChild(btn);
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
return () => {
|
|
15
|
+
const render = () => {
|
|
23
16
|
container.innerHTML = '';
|
|
17
|
+
|
|
18
|
+
presets.forEach((preset) => {
|
|
19
|
+
const btn = document.createElement('button');
|
|
20
|
+
btn.type = 'button';
|
|
21
|
+
btn.className = 'geometry-preset-btn';
|
|
22
|
+
btn.dataset.preset = preset.id;
|
|
23
|
+
btn.textContent = preset.label;
|
|
24
|
+
btn.addEventListener('click', () => onPreset?.(preset));
|
|
25
|
+
container.appendChild(btn);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const savedList = typeof getSavedPresets === 'function' ? getSavedPresets() : [];
|
|
29
|
+
if (savedList.length === 0) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const divider = document.createElement('span');
|
|
34
|
+
divider.className = 'geometry-preset-divider';
|
|
35
|
+
divider.setAttribute('aria-hidden', 'true');
|
|
36
|
+
container.appendChild(divider);
|
|
37
|
+
|
|
38
|
+
savedList.forEach((preset) => {
|
|
39
|
+
const wrap = document.createElement('div');
|
|
40
|
+
wrap.className = 'geometry-preset-saved-item';
|
|
41
|
+
|
|
42
|
+
const btn = document.createElement('button');
|
|
43
|
+
btn.type = 'button';
|
|
44
|
+
btn.className = 'geometry-preset-btn geometry-preset-btn-saved';
|
|
45
|
+
btn.dataset.preset = preset.id;
|
|
46
|
+
btn.textContent = preset.label;
|
|
47
|
+
btn.title = preset.bgOmitted
|
|
48
|
+
? '点击加载(背景图未存入,需重新上传)'
|
|
49
|
+
: '点击加载';
|
|
50
|
+
btn.addEventListener('click', () => onSavedPreset?.(preset));
|
|
51
|
+
|
|
52
|
+
const deleteBtn = document.createElement('button');
|
|
53
|
+
deleteBtn.type = 'button';
|
|
54
|
+
deleteBtn.className = 'geometry-preset-delete';
|
|
55
|
+
deleteBtn.innerHTML = DELETE_ICON_SVG;
|
|
56
|
+
deleteBtn.setAttribute('aria-label', `删除 ${preset.label}`);
|
|
57
|
+
deleteBtn.addEventListener('click', (event) => {
|
|
58
|
+
event.preventDefault();
|
|
59
|
+
event.stopPropagation();
|
|
60
|
+
onDeleteSaved?.(preset);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
wrap.appendChild(btn);
|
|
64
|
+
wrap.appendChild(deleteBtn);
|
|
65
|
+
container.appendChild(wrap);
|
|
66
|
+
}); };
|
|
67
|
+
|
|
68
|
+
render();
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
refresh: render,
|
|
72
|
+
destroy() {
|
|
73
|
+
container.innerHTML = '';
|
|
74
|
+
}
|
|
24
75
|
};
|
|
25
76
|
}
|
|
@@ -1,9 +1,80 @@
|
|
|
1
1
|
import { PARAM_SCHEMA } from './constants.js';
|
|
2
2
|
import { getByPath, paramFieldId } from './param-utils.js';
|
|
3
|
+
import { parseControlPointPath } from './track-helpers.js';
|
|
4
|
+
|
|
5
|
+
function parseParamValue(raw, field) {
|
|
6
|
+
const num = Number(raw);
|
|
7
|
+
return field.step >= 1 ? Math.round(num) : num;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function clampParamValue(value, field) {
|
|
11
|
+
const num = parseParamValue(value, field);
|
|
12
|
+
return Math.min(field.max, Math.max(field.min, num));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function formatParamOutput(value, field) {
|
|
16
|
+
const num = parseParamValue(value, field);
|
|
17
|
+
if (field.step >= 1) return String(num);
|
|
18
|
+
const stepText = String(field.step);
|
|
19
|
+
const decimals = stepText.includes('.') ? stepText.split('.')[1].length : 0;
|
|
20
|
+
return String(Number(num.toFixed(decimals)));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function applyParamInput(input, output, field, value, onParamChange) {
|
|
24
|
+
const num = clampParamValue(value, field);
|
|
25
|
+
input.value = String(num);
|
|
26
|
+
output.textContent = formatParamOutput(num, field);
|
|
27
|
+
onParamChange(field.path, num);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const STEP_HOLD_DELAY_MS = 400;
|
|
31
|
+
const STEP_REPEAT_INTERVAL_MS = 80;
|
|
32
|
+
|
|
33
|
+
function bindStepButtonHold(btn, stepOnce, { onHoldStart, onHoldEnd } = {}) {
|
|
34
|
+
let delayTimer = null;
|
|
35
|
+
let repeatTimer = null;
|
|
36
|
+
let holding = false;
|
|
37
|
+
|
|
38
|
+
const clearTimers = () => {
|
|
39
|
+
if (delayTimer != null) {
|
|
40
|
+
clearTimeout(delayTimer);
|
|
41
|
+
delayTimer = null;
|
|
42
|
+
}
|
|
43
|
+
if (repeatTimer != null) {
|
|
44
|
+
clearInterval(repeatTimer);
|
|
45
|
+
repeatTimer = null;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const stopHold = () => {
|
|
50
|
+
if (!holding) return;
|
|
51
|
+
holding = false;
|
|
52
|
+
clearTimers();
|
|
53
|
+
onHoldEnd?.();
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
btn.addEventListener('pointerdown', (event) => {
|
|
57
|
+
event.preventDefault();
|
|
58
|
+
btn.setPointerCapture(event.pointerId);
|
|
59
|
+
holding = true;
|
|
60
|
+
onHoldStart?.();
|
|
61
|
+
stepOnce();
|
|
62
|
+
clearTimers();
|
|
63
|
+
delayTimer = setTimeout(() => {
|
|
64
|
+
repeatTimer = setInterval(stepOnce, STEP_REPEAT_INTERVAL_MS);
|
|
65
|
+
}, STEP_HOLD_DELAY_MS);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
btn.addEventListener('pointerup', stopHold);
|
|
69
|
+
btn.addEventListener('pointercancel', stopHold);
|
|
70
|
+
btn.addEventListener('lostpointercapture', stopHold);
|
|
71
|
+
}
|
|
3
72
|
|
|
4
73
|
export function bindParamsPanel(paramsForm, {
|
|
5
74
|
getParams,
|
|
6
75
|
onParamChange,
|
|
76
|
+
onControlPointAdjustStart,
|
|
77
|
+
onControlPointAdjustEnd,
|
|
7
78
|
schema = PARAM_SCHEMA,
|
|
8
79
|
fadeSectionTitle = '动画',
|
|
9
80
|
animationCheckboxes = [
|
|
@@ -33,21 +104,55 @@ export function bindParamsPanel(paramsForm, {
|
|
|
33
104
|
|
|
34
105
|
row.innerHTML = `
|
|
35
106
|
<label for="${id}">${field.label}</label>
|
|
36
|
-
<
|
|
37
|
-
|
|
107
|
+
<div class="param-stepper">
|
|
108
|
+
<button type="button" class="param-step-btn" data-step="-1" aria-label="${field.label} 减小">−</button>
|
|
109
|
+
<input type="range" id="${id}" min="${field.min}" max="${field.max}" step="${field.step}" value="${val}" />
|
|
110
|
+
<button type="button" class="param-step-btn" data-step="1" aria-label="${field.label} 增大">+</button>
|
|
111
|
+
</div>
|
|
112
|
+
<output for="${id}">${formatParamOutput(val, field)}</output>
|
|
38
113
|
`;
|
|
39
114
|
|
|
40
115
|
const input = row.querySelector('input');
|
|
41
116
|
const output = row.querySelector('output');
|
|
117
|
+
const stepButtons = row.querySelectorAll('.param-step-btn');
|
|
42
118
|
|
|
43
119
|
input.addEventListener('input', () => {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
120
|
+
applyParamInput(input, output, field, input.value, onParamChange);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const controlHint = parseControlPointPath(field.path);
|
|
124
|
+
|
|
125
|
+
stepButtons.forEach((btn) => {
|
|
126
|
+
const delta = Number(btn.dataset.step) * field.step;
|
|
127
|
+
const stepOnce = () => {
|
|
128
|
+
applyParamInput(input, output, field, Number(input.value) + delta, onParamChange);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const holdCallbacks = controlHint
|
|
132
|
+
? {
|
|
133
|
+
onHoldStart: () => onControlPointAdjustStart?.(controlHint),
|
|
134
|
+
onHoldEnd: () => onControlPointAdjustEnd?.()
|
|
135
|
+
}
|
|
136
|
+
: {};
|
|
137
|
+
|
|
138
|
+
bindStepButtonHold(btn, stepOnce, holdCallbacks);
|
|
49
139
|
});
|
|
50
140
|
|
|
141
|
+
if (controlHint) {
|
|
142
|
+
row.classList.add('param-row-cp');
|
|
143
|
+
row.dataset.controlPoint = controlHint.point;
|
|
144
|
+
row.dataset.controlAxis = controlHint.axis;
|
|
145
|
+
|
|
146
|
+
const startControlAdjust = () => {
|
|
147
|
+
onControlPointAdjustStart?.(controlHint);
|
|
148
|
+
window.addEventListener('pointerup', () => {
|
|
149
|
+
onControlPointAdjustEnd?.();
|
|
150
|
+
}, { once: true });
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
input.addEventListener('pointerdown', startControlAdjust);
|
|
154
|
+
}
|
|
155
|
+
|
|
51
156
|
sections.get(field.section).appendChild(row);
|
|
52
157
|
});
|
|
53
158
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { deepClone } from './param-utils.js';
|
|
2
|
+
|
|
3
|
+
export const SAVED_PRESETS_STORAGE_KEY = 'bezier-slider-demo-saved-presets';
|
|
4
|
+
const MAX_SAVED_COUNT = 16;
|
|
5
|
+
const MAX_BG_DATA_URL_LENGTH = 900_000;
|
|
6
|
+
|
|
7
|
+
export function loadSavedPresets() {
|
|
8
|
+
try {
|
|
9
|
+
const raw = localStorage.getItem(SAVED_PRESETS_STORAGE_KEY);
|
|
10
|
+
if (!raw) return [];
|
|
11
|
+
const list = JSON.parse(raw);
|
|
12
|
+
return Array.isArray(list) ? list : [];
|
|
13
|
+
} catch {
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function writeSavedPresets(list) {
|
|
19
|
+
localStorage.setItem(SAVED_PRESETS_STORAGE_KEY, JSON.stringify(list));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function removeSavedPreset(id) {
|
|
23
|
+
const next = loadSavedPresets().filter((item) => item.id !== id);
|
|
24
|
+
writeSavedPresets(next);
|
|
25
|
+
return next;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function buildPresetSnapshot({
|
|
29
|
+
label,
|
|
30
|
+
params,
|
|
31
|
+
bgUrl,
|
|
32
|
+
bgFileName,
|
|
33
|
+
bgNaturalSize
|
|
34
|
+
}) {
|
|
35
|
+
const includeBg = typeof bgUrl === 'string'
|
|
36
|
+
&& bgUrl.length > 0
|
|
37
|
+
&& bgUrl.length <= MAX_BG_DATA_URL_LENGTH;
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
id: `saved-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
|
41
|
+
label: label.trim(),
|
|
42
|
+
savedAt: Date.now(),
|
|
43
|
+
params: deepClone(params),
|
|
44
|
+
bgUrl: includeBg ? bgUrl : null,
|
|
45
|
+
bgFileName: includeBg ? (bgFileName || '') : '',
|
|
46
|
+
bgNaturalSize: deepClone(bgNaturalSize),
|
|
47
|
+
bgOmitted: Boolean(bgUrl && !includeBg)
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function savePresetSnapshot(snapshot) {
|
|
52
|
+
const list = loadSavedPresets();
|
|
53
|
+
if (list.length >= MAX_SAVED_COUNT) {
|
|
54
|
+
throw new Error(`最多保存 ${MAX_SAVED_COUNT} 个配置,请先点击快捷按钮右上角删除旧配置`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
list.push(snapshot);
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
writeSavedPresets(list);
|
|
61
|
+
} catch {
|
|
62
|
+
throw new Error('保存失败:浏览器存储空间不足,请删除部分已存配置或减少背景图大小');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return list;
|
|
66
|
+
}
|
|
@@ -48,21 +48,107 @@ export function applyBgLayer(bgLayer, bgUrl = null) {
|
|
|
48
48
|
bgLayer.style.backgroundSize = '100% 100%';
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
const CONTROL_POINT_META = {
|
|
52
|
+
p0: { label: 'P0', color: '#38bdf8', influence: [0, 0.45], hint: '左端起点' },
|
|
53
|
+
p1: { label: 'P1', color: '#fb923c', influence: [0.15, 0.85], hint: '中间弧度' },
|
|
54
|
+
p2: { label: 'P2', color: '#c084fc', influence: [0.55, 1], hint: '右端终点' }
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export function parseControlPointPath(path) {
|
|
58
|
+
const match = String(path).match(/^bezier\.fitted\.(p[012])\.([xy])$/);
|
|
59
|
+
if (!match) return null;
|
|
60
|
+
return { point: match[1], axis: match[2] };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function sampleCurveSegment(curve, tStart, tEnd, pointOnCurve, steps = 20) {
|
|
64
|
+
const parts = [];
|
|
65
|
+
for (let i = 0; i <= steps; i++) {
|
|
66
|
+
const t = tStart + (tEnd - tStart) * (i / steps);
|
|
67
|
+
const { x, y } = pointOnCurve(t, curve);
|
|
68
|
+
parts.push(`${i === 0 ? 'M' : 'L'} ${x.toFixed(2)} ${y.toFixed(2)}`);
|
|
69
|
+
}
|
|
70
|
+
return parts.join(' ');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function setSvgContent(svg, layout, BezierSliderClass, activeControl) {
|
|
74
|
+
const { bezier } = layout;
|
|
75
|
+
if (!bezier) return;
|
|
76
|
+
|
|
77
|
+
const pointOnCurve = BezierSliderClass.pointOnCurve;
|
|
78
|
+
const curvePath = BezierSliderClass.bezierPath(bezier);
|
|
79
|
+
const { p0, p1, p2 } = bezier;
|
|
80
|
+
const activeKey = activeControl?.point ?? null;
|
|
81
|
+
const activeMeta = activeKey ? CONTROL_POINT_META[activeKey] : null;
|
|
82
|
+
|
|
83
|
+
let influencePath = '';
|
|
84
|
+
if (activeMeta) {
|
|
85
|
+
const [t0, t1] = activeMeta.influence;
|
|
86
|
+
influencePath = `<path class="debug-influence" fill="none" stroke="${activeMeta.color}" stroke-width="5" stroke-linecap="round" opacity="0.55" d="${sampleCurveSegment(bezier, t0, t1, pointOnCurve)}"/>`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let axisHint = '';
|
|
90
|
+
if (activeControl && activeMeta) {
|
|
91
|
+
const pt = bezier[activeControl.point];
|
|
92
|
+
const len = Math.min(56, Math.max(36, layout.width * 0.1));
|
|
93
|
+
const half = len / 2;
|
|
94
|
+
const color = activeMeta.color;
|
|
95
|
+
if (activeControl.axis === 'x') {
|
|
96
|
+
axisHint = `
|
|
97
|
+
<line class="debug-axis" x1="${pt.x - half}" y1="${pt.y}" x2="${pt.x + half}" y2="${pt.y}" stroke="${color}" stroke-width="2" marker-start="url(#debugArrow)" marker-end="url(#debugArrow)" opacity="0.95"/>
|
|
98
|
+
<text class="debug-axis-label" x="${pt.x + half + 6}" y="${pt.y + 4}" fill="${color}">X →</text>`;
|
|
99
|
+
} else {
|
|
100
|
+
axisHint = `
|
|
101
|
+
<line class="debug-axis" x1="${pt.x}" y1="${pt.y - half}" x2="${pt.x}" y2="${pt.y + half}" stroke="${color}" stroke-width="2" marker-start="url(#debugArrow)" marker-end="url(#debugArrow)" opacity="0.95"/>
|
|
102
|
+
<text class="debug-axis-label" x="${pt.x + 6}" y="${pt.y + half + 14}" fill="${color}">Y ↓</text>`;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const controlPoints = ['p0', 'p1', 'p2'].map((key) => {
|
|
107
|
+
const pt = bezier[key];
|
|
108
|
+
const meta = CONTROL_POINT_META[key];
|
|
109
|
+
const isActive = key === activeKey;
|
|
110
|
+
const r = isActive ? 7 : 5;
|
|
111
|
+
const opacity = isActive ? 1 : 0.55;
|
|
112
|
+
const stroke = isActive ? '#fff' : meta.color;
|
|
113
|
+
const fill = isActive ? meta.color : 'rgba(0,0,0,0.35)';
|
|
114
|
+
const pulse = isActive
|
|
115
|
+
? `<circle cx="${pt.x}" cy="${pt.y}" r="14" fill="none" stroke="${meta.color}" stroke-width="2" opacity="0.45" class="debug-point-pulse"/>`
|
|
116
|
+
: '';
|
|
117
|
+
const hint = isActive
|
|
118
|
+
? `<text class="debug-point-hint" x="${pt.x}" y="${pt.y - 16}" fill="${meta.color}" text-anchor="middle">${meta.hint} · 调${activeControl.axis.toUpperCase()}</text>`
|
|
119
|
+
: '';
|
|
120
|
+
return `${pulse}
|
|
121
|
+
<circle class="debug-point" data-point="${key}" cx="${pt.x}" cy="${pt.y}" r="${r}" fill="${fill}" stroke="${stroke}" stroke-width="2" opacity="${opacity}"/>
|
|
122
|
+
<text class="debug-point-label" x="${pt.x}" y="${pt.y + 4}" fill="${isActive ? '#fff' : meta.color}" text-anchor="middle" font-size="10" font-weight="600">${meta.label}</text>
|
|
123
|
+
${hint}`;
|
|
124
|
+
}).join('');
|
|
125
|
+
|
|
126
|
+
svg.innerHTML = `
|
|
127
|
+
<defs>
|
|
128
|
+
<marker id="debugArrow" viewBox="0 0 10 10" refX="5" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse">
|
|
129
|
+
<path d="M 0 0 L 10 5 L 0 10 z" fill="currentColor"/>
|
|
130
|
+
</marker>
|
|
131
|
+
</defs>
|
|
132
|
+
${influencePath}
|
|
133
|
+
<path class="debug-curve" fill="none" stroke="lime" stroke-width="2" stroke-dasharray="6 4" opacity="0.85" d="${curvePath}"/>
|
|
134
|
+
<line class="debug-polygon" x1="${p0.x}" y1="${p0.y}" x2="${p1.x}" y2="${p1.y}" stroke="rgba(255,255,255,0.22)" stroke-width="1" stroke-dasharray="4 3"/>
|
|
135
|
+
<line class="debug-polygon" x1="${p1.x}" y1="${p1.y}" x2="${p2.x}" y2="${p2.y}" stroke="rgba(255,255,255,0.22)" stroke-width="1" stroke-dasharray="4 3"/>
|
|
136
|
+
<g class="debug-axis-hints" color="${activeMeta?.color ?? '#fff'}">${axisHint}</g>
|
|
137
|
+
<g class="debug-control-points">${controlPoints}</g>`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function updateDebugTrack(trackLayer, layout, visible, BezierSliderClass, activeControl = null) {
|
|
52
141
|
let svg = trackLayer.querySelector('.debug-track');
|
|
53
142
|
if (!visible) {
|
|
54
143
|
svg?.remove();
|
|
55
144
|
return;
|
|
56
145
|
}
|
|
57
146
|
if (!svg) {
|
|
58
|
-
trackLayer.insertAdjacentHTML(
|
|
59
|
-
'afterbegin',
|
|
60
|
-
'<svg class="debug-track" preserveAspectRatio="none"><path fill="none" stroke="lime" stroke-width="2" stroke-dasharray="6 4" opacity="0.85"/></svg>'
|
|
61
|
-
);
|
|
147
|
+
trackLayer.insertAdjacentHTML('afterbegin', '<svg class="debug-track" preserveAspectRatio="none"></svg>');
|
|
62
148
|
svg = trackLayer.querySelector('.debug-track');
|
|
63
149
|
}
|
|
64
150
|
svg.setAttribute('viewBox', getTrackViewBox(layout));
|
|
65
|
-
svg
|
|
151
|
+
setSvgContent(svg, layout, BezierSliderClass, activeControl);
|
|
66
152
|
}
|
|
67
153
|
|
|
68
154
|
export function readImageFile(file) {
|
package/dist/bezier-slider.mjs
CHANGED
|
@@ -32,10 +32,10 @@ var k = (c, a, o) => (q(c, typeof a != "symbol" ? a + "" : a, o), o);
|
|
|
32
32
|
fitted: {
|
|
33
33
|
p0: { x: 0.015, y: 0.48 },
|
|
34
34
|
// 左端略低(接近原默认高度)
|
|
35
|
-
p1: { x: 0.48, y:
|
|
36
|
-
//
|
|
37
|
-
p2: { x: 0.985, y:
|
|
38
|
-
//
|
|
35
|
+
p1: { x: 0.48, y: 0.48 },
|
|
36
|
+
// 控制点 Y 直接参与弧度(不再被 rightTilt 覆盖)
|
|
37
|
+
p2: { x: 0.985, y: 0.48 }
|
|
38
|
+
// 右端 Y 为基准,rightEndOffset 在其上叠加
|
|
39
39
|
},
|
|
40
40
|
curveSmooth: 0.1,
|
|
41
41
|
// 略平滑,弧线仍清晰
|
|
@@ -240,20 +240,17 @@ var k = (c, a, o) => (q(c, typeof a != "symbol" ? a + "" : a, o), o);
|
|
|
240
240
|
};
|
|
241
241
|
}
|
|
242
242
|
function O(e, t, s) {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
};
|
|
247
|
-
return {
|
|
248
|
-
p0: e.p0,
|
|
249
|
-
p1: {
|
|
250
|
-
x: e.p1.x + (r.x - e.p1.x) * t,
|
|
251
|
-
y: e.p1.y + (r.y - e.p1.y) * t
|
|
252
|
-
},
|
|
243
|
+
return t ? {
|
|
244
|
+
p0: { ...e.p0 },
|
|
245
|
+
p1: { ...e.p1 },
|
|
253
246
|
p2: {
|
|
254
247
|
x: e.p2.x,
|
|
255
|
-
y: e.p2.y
|
|
248
|
+
y: e.p2.y - s * t
|
|
256
249
|
}
|
|
250
|
+
} : {
|
|
251
|
+
p0: { ...e.p0 },
|
|
252
|
+
p1: { ...e.p1 },
|
|
253
|
+
p2: { ...e.p2 }
|
|
257
254
|
};
|
|
258
255
|
}
|
|
259
256
|
function D(e, { t, degrees: s }) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bezier-slider",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "二次贝塞尔弧形图标滑块组件,支持原生 JavaScript、Vue 3 和 React",
|
|
5
5
|
"main": "dist/bezier-slider.mjs",
|
|
6
6
|
"module": "dist/bezier-slider.mjs",
|
|
@@ -23,13 +23,14 @@
|
|
|
23
23
|
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"
|
|
27
|
-
"react": "
|
|
28
|
-
"
|
|
26
|
+
"react": ">=18",
|
|
27
|
+
"react-dom": ">=18",
|
|
28
|
+
"vue": ">=3.4"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"@vitejs/plugin-react": "^4.2.1",
|
|
32
32
|
"@vitejs/plugin-vue": "^4.6.2",
|
|
33
|
+
"highlight.js": "^11.11.1",
|
|
33
34
|
"vite": "^4.5.2"
|
|
34
35
|
},
|
|
35
36
|
"peerDependencies": {
|
|
@@ -43,8 +43,8 @@
|
|
|
43
43
|
// 基础二次贝塞尔三控制点(归一化坐标 0~1,y 越大越低)
|
|
44
44
|
fitted: {
|
|
45
45
|
p0: { x: 0.015, y: 0.48 }, // 左端略低(接近原默认高度)
|
|
46
|
-
p1: { x: 0.48, y:
|
|
47
|
-
p2: { x: 0.985, y:
|
|
46
|
+
p1: { x: 0.48, y: 0.48 }, // 控制点 Y 直接参与弧度(不再被 rightTilt 覆盖)
|
|
47
|
+
p2: { x: 0.985, y: 0.48 } // 右端 Y 为基准,rightEndOffset 在其上叠加
|
|
48
48
|
},
|
|
49
49
|
curveSmooth: 0.1, // 略平滑,弧线仍清晰
|
|
50
50
|
rightTilt: 1,
|
|
@@ -373,25 +373,24 @@
|
|
|
373
373
|
}
|
|
374
374
|
|
|
375
375
|
/**
|
|
376
|
-
*
|
|
377
|
-
*
|
|
376
|
+
* 右端高度微调:仅在 fitted 基础上叠加 P2 的 Y 偏移
|
|
377
|
+
* 不修改 P1,以便控制点 P1 的 X/Y 滑块直接生效
|
|
378
378
|
*/
|
|
379
379
|
function easeRightTilt(bezier, tilt, rightEndOffset) {
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
380
|
+
if (!tilt) {
|
|
381
|
+
return {
|
|
382
|
+
p0: { ...bezier.p0 },
|
|
383
|
+
p1: { ...bezier.p1 },
|
|
384
|
+
p2: { ...bezier.p2 }
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
386
388
|
return {
|
|
387
|
-
p0: bezier.p0,
|
|
388
|
-
p1: {
|
|
389
|
-
x: bezier.p1.x + (targetP1.x - bezier.p1.x) * tilt,
|
|
390
|
-
y: bezier.p1.y + (targetP1.y - bezier.p1.y) * tilt
|
|
391
|
-
},
|
|
389
|
+
p0: { ...bezier.p0 },
|
|
390
|
+
p1: { ...bezier.p1 },
|
|
392
391
|
p2: {
|
|
393
392
|
x: bezier.p2.x,
|
|
394
|
-
y: bezier.p2.y
|
|
393
|
+
y: bezier.p2.y - rightEndOffset * tilt
|
|
395
394
|
}
|
|
396
395
|
};
|
|
397
396
|
}
|