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 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
- <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>
@@ -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
 
@@ -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
- codeContent.textContent = formatter(params, getCodePreviewOptions());
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 unbindGeometryBar = bindGeometryPresetBar(geometryPresetBar, {
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
- const unbindCopy = bindCopyButton(codeCopyBtn, () => codeContent.textContent);
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
- if (slider) {
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
- unbindGeometryBar();
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
+ }
@@ -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), 640px);
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;
@@ -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
  }
@@ -86,8 +86,8 @@ export const GEOMETRY_PRESETS = [
86
86
  bezier: {
87
87
  fitted: {
88
88
  p0: { x: 0.015, y: 0.48 },
89
- p1: { x: 0.48, y: 1.08 },
90
- p2: { x: 0.985, y: -0.02 }
89
+ p1: { x: 0.48, y: 0.48 },
90
+ p2: { x: 0.985, y: 0.48 }
91
91
  },
92
92
  curveSmooth: 0.1,
93
93
  rightTilt: 1,
@@ -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
- <input type="range" id="${id}" min="${field.min}" max="${field.max}" step="${field.step}" value="${val}" />
37
- <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>
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
- const num = field.step >= 1
45
- ? Math.round(Number(input.value))
46
- : Number(input.value);
47
- onParamChange(field.path, num);
48
- output.textContent = String(num);
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
- export function updateDebugTrack(trackLayer, layout, visible, BezierSliderClass) {
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.querySelector('path').setAttribute('d', BezierSliderClass.bezierPath(layout.bezier));
151
+ setSvgContent(svg, layout, BezierSliderClass, activeControl);
66
152
  }
67
153
 
68
154
  export function readImageFile(file) {
@@ -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: 1.08 },
36
- // 控制点下压,保留滑轨弧度
37
- p2: { x: 0.985, y: -0.02 }
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
- const i = e.p0.y, n = i - s, r = {
244
- x: 0.45,
245
- y: (i + n) / 2 + 0.05
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 + (n - e.p2.y) * t
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.2",
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": {
@@ -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: 1.08 }, // 控制点下压,保留滑轨弧度
47
- p2: { x: 0.985, y: -0.02 } // 右端偏高
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
- * 通过调整 P1、P2y 实现,保持二次贝塞尔类型不变
376
+ * 右端高度微调:仅在 fitted 基础上叠加 P2 的 Y 偏移
377
+ * 不修改 P1,以便控制点 P1 X/Y 滑块直接生效
378
378
  */
379
379
  function easeRightTilt(bezier, tilt, rightEndOffset) {
380
- const leftY = bezier.p0.y;
381
- const targetP2Y = leftY - rightEndOffset;
382
- const targetP1 = {
383
- x: 0.45,
384
- y: (leftY + targetP2Y) / 2 + 0.05
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 + (targetP2Y - bezier.p2.y) * tilt
393
+ y: bezier.p2.y - rightEndOffset * tilt
395
394
  }
396
395
  };
397
396
  }