bezier-slider 1.0.3 → 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/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
- <button
22
- type="button"
23
- class="params-reset"
24
- id="paramsReset"
25
- aria-label="恢复默认"
26
- data-tooltip="恢复默认"
27
- ></button>
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>
@@ -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
 
@@ -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;
@@ -108,7 +115,8 @@ document.addEventListener('DOMContentLoaded', () => {
108
115
 
109
116
  function updateParamsPreview() {
110
117
  const formatter = CODE_FORMATTERS[currentCodeTab] ?? CODE_FORMATTERS.native;
111
- codeContent.textContent = formatter(params, getCodePreviewOptions());
118
+ const source = formatter(params, getCodePreviewOptions());
119
+ renderHighlightedCode(codeContent, source, getCodeTabLanguage(currentCodeTab));
112
120
  legendCenterT.textContent = `centerT = ${params.centerT}`;
113
121
  }
114
122
 
@@ -213,6 +221,55 @@ document.addEventListener('DOMContentLoaded', () => {
213
221
  scheduleRebuild();
214
222
  }
215
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
+
216
273
  const unbindPanel = bindParamsPanel(paramsForm, {
217
274
  getParams: () => params,
218
275
  onParamChange: handleParamChange,
@@ -220,11 +277,14 @@ document.addEventListener('DOMContentLoaded', () => {
220
277
  onControlPointAdjustEnd: () => setActiveControl(null),
221
278
  schema: PARAM_SCHEMA
222
279
  });
223
- const unbindGeometryBar = bindGeometryPresetBar(geometryPresetBar, {
224
- onPreset: handleGeometryPreset
280
+ const geometryBarApi = bindGeometryPresetBar(geometryPresetBar, {
281
+ onPreset: handleGeometryPreset,
282
+ onSavedPreset: applySavedPreset,
283
+ onDeleteSaved: handleDeleteSavedPreset
225
284
  });
226
285
  const unbindReset = bindResetButton(paramsReset, handleReset);
227
- const unbindCopy = bindCopyButton(codeCopyBtn, () => codeContent.textContent);
286
+ paramsSave?.addEventListener('click', handleSavePreset);
287
+ const unbindCopy = bindCopyButton(codeCopyBtn, () => getPlainCodeText(codeContent));
228
288
 
229
289
  applyDisplayLayout();
230
290
  applyBgLayer(carouselBg, null);
@@ -272,7 +332,7 @@ document.addEventListener('DOMContentLoaded', () => {
272
332
 
273
333
  window.addEventListener('beforeunload', () => {
274
334
  unbindPanel();
275
- unbindGeometryBar();
335
+ geometryBarApi.destroy();
276
336
  unbindReset();
277
337
  unbindCopy();
278
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
+ }
@@ -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>`;
@@ -110,11 +110,24 @@
110
110
  50% { opacity: 0.75; transform: scale(1.25); transform-origin: center; transform-box: fill-box; }
111
111
  }
112
112
 
113
- .param-row-cp.is-adjusting {
114
- background: rgba(252, 211, 77, 0.08);
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;
115
122
  border-radius: 8px;
116
- margin: 0 -6px;
117
- padding: 4px 6px;
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);
118
131
  }
119
132
 
120
133
  .param-row-cp.is-adjusting label {
@@ -150,12 +163,79 @@
150
163
  display: flex;
151
164
  flex-wrap: wrap;
152
165
  justify-content: center;
166
+ align-items: center;
153
167
  gap: 8px;
154
- max-width: min(calc(100vw - 32px), 640px);
168
+ max-width: min(calc(100vw - 32px), 900px);
155
169
  transform: translateX(-50%);
156
170
  pointer-events: none;
157
171
  }
158
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
+
159
239
  .geometry-preset-bar .geometry-preset-btn {
160
240
  pointer-events: auto;
161
241
  padding: 7px 14px;
@@ -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 40px;
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: 12px;
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
- max-width: 480px;
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
- onPreset
6
+ getSavedPresets = loadSavedPresets,
7
+ onPreset,
8
+ onSavedPreset,
9
+ onDeleteSaved
6
10
  } = {}) {
7
- if (!container || typeof onPreset !== 'function') {
8
- return () => {};
11
+ if (!container) {
12
+ return { refresh() {}, destroy() {} };
9
13
  }
10
14
 
11
- container.innerHTML = '';
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
  }
@@ -2,6 +2,74 @@ import { PARAM_SCHEMA } from './constants.js';
2
2
  import { getByPath, paramFieldId } from './param-utils.js';
3
3
  import { parseControlPointPath } from './track-helpers.js';
4
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
+ }
72
+
5
73
  export function bindParamsPanel(paramsForm, {
6
74
  getParams,
7
75
  onParamChange,
@@ -36,36 +104,53 @@ export function bindParamsPanel(paramsForm, {
36
104
 
37
105
  row.innerHTML = `
38
106
  <label for="${id}">${field.label}</label>
39
- <input type="range" id="${id}" min="${field.min}" max="${field.max}" step="${field.step}" value="${val}" />
40
- <output for="${id}">${val}</output>
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>
41
113
  `;
42
114
 
43
115
  const input = row.querySelector('input');
44
116
  const output = row.querySelector('output');
117
+ const stepButtons = row.querySelectorAll('.param-step-btn');
45
118
 
46
119
  input.addEventListener('input', () => {
47
- const num = field.step >= 1
48
- ? Math.round(Number(input.value))
49
- : Number(input.value);
50
- onParamChange(field.path, num);
51
- output.textContent = String(num);
120
+ applyParamInput(input, output, field, input.value, onParamChange);
52
121
  });
53
122
 
54
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);
139
+ });
140
+
55
141
  if (controlHint) {
56
142
  row.classList.add('param-row-cp');
57
143
  row.dataset.controlPoint = controlHint.point;
58
144
  row.dataset.controlAxis = controlHint.axis;
59
145
 
60
- input.addEventListener('pointerdown', () => {
146
+ const startControlAdjust = () => {
61
147
  onControlPointAdjustStart?.(controlHint);
62
148
  window.addEventListener('pointerup', () => {
63
149
  onControlPointAdjustEnd?.();
64
150
  }, { once: true });
65
- });
66
- input.addEventListener('blur', () => {
67
- onControlPointAdjustEnd?.();
68
- });
151
+ };
152
+
153
+ input.addEventListener('pointerdown', startControlAdjust);
69
154
  }
70
155
 
71
156
  sections.get(field.section).appendChild(row);
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bezier-slider",
3
- "version": "1.0.3",
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
- "vue": "^3.4.21",
27
- "react": "^18.2.0",
28
- "react-dom": "^18.2.0"
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": {