@thinkpixellab-public/px-vue 4.1.24 → 4.1.26
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/bases/PxBaseItemsSelect.vue +7 -3
- package/components/PxCard.vue +1 -1
- package/components/PxCorners.vue +233 -0
- package/components/PxDatePicker.vue +1 -1
- package/components/PxMenu.vue +2 -2
- package/components/PxScrubber.vue +8 -8
- package/composables/useCornerSmoothing.js +102 -113
- package/package.json +1 -1
- package/stories/PxCorners.stories.js +187 -0
- package/storybook/sb-styles.scss +11 -6
- package/utils/smootherCorners.js +395 -0
|
@@ -139,15 +139,19 @@ export default {
|
|
|
139
139
|
immediate: true,
|
|
140
140
|
handler(nv, ov) {
|
|
141
141
|
// check for matching values
|
|
142
|
-
|
|
142
|
+
const nvArray = ensureSet(nv);
|
|
143
|
+
const ovArray = ensureSet(ov);
|
|
144
|
+
|
|
145
|
+
if (nvArray.length === ovArray.length) {
|
|
143
146
|
let match = true;
|
|
144
|
-
for (let i = 0; i <
|
|
145
|
-
match = match &&
|
|
147
|
+
for (let i = 0; i < nvArray.length; i++) {
|
|
148
|
+
match = match && nvArray[i] === ovArray[i];
|
|
146
149
|
}
|
|
147
150
|
if (match) {
|
|
148
151
|
return;
|
|
149
152
|
}
|
|
150
153
|
}
|
|
154
|
+
|
|
151
155
|
this.setSelection(nv);
|
|
152
156
|
},
|
|
153
157
|
},
|
package/components/PxCard.vue
CHANGED
|
@@ -21,7 +21,7 @@ export default {
|
|
|
21
21
|
.px-card {
|
|
22
22
|
/// Configuration defautls for px-calendar
|
|
23
23
|
$-defaults: (
|
|
24
|
-
'px-card.background-color':
|
|
24
|
+
'px-card.background-color': site-var(page-bg),
|
|
25
25
|
'px-card.padding': 1rem,
|
|
26
26
|
'px-card.border-radius': 0.5rem,
|
|
27
27
|
'px-card.box-shadow': depth-shadow(20, 0.1, black),
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Describe this component...
|
|
3
|
+
-->
|
|
4
|
+
<template>
|
|
5
|
+
<div
|
|
6
|
+
v-if="smoothingEnabled"
|
|
7
|
+
:class="bem()"
|
|
8
|
+
ref="el"
|
|
9
|
+
:style="{
|
|
10
|
+
position: 'relative',
|
|
11
|
+
clipPath: renderMode === 'clip' ? smoothCornersPath : null,
|
|
12
|
+
mask: renderMode === 'mask' ? smoothCornersPath : null,
|
|
13
|
+
...overrideStyles,
|
|
14
|
+
}"
|
|
15
|
+
>
|
|
16
|
+
<div
|
|
17
|
+
v-if="hasBorder"
|
|
18
|
+
:style="{
|
|
19
|
+
position: 'absolute',
|
|
20
|
+
inset: 0,
|
|
21
|
+
clipPath: renderMode === 'clip' ? borderMaskPath : null,
|
|
22
|
+
mask: renderMode === 'mask' ? borderMaskPath : null,
|
|
23
|
+
backgroundColor: computedBorderColor,
|
|
24
|
+
touchAction: 'none',
|
|
25
|
+
}"
|
|
26
|
+
></div>
|
|
27
|
+
|
|
28
|
+
<slot />
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<div v-else>
|
|
32
|
+
<slot />
|
|
33
|
+
</div>
|
|
34
|
+
</template>
|
|
35
|
+
|
|
36
|
+
<script>
|
|
37
|
+
import { getSmoothCornersPath, getBorderMaskClipPath } from '../utils/smootherCorners';
|
|
38
|
+
|
|
39
|
+
export default {
|
|
40
|
+
name: 'px-corners',
|
|
41
|
+
props: {
|
|
42
|
+
smoothingEnabled: { type: Boolean, default: true },
|
|
43
|
+
smoothing: { type: Number, default: 1 },
|
|
44
|
+
maxRadius: { type: [Number, String], default: Infinity },
|
|
45
|
+
minRadius: { type: [Number, String], default: 0 },
|
|
46
|
+
radius: { type: [Array, Number, String], default: 'auto' },
|
|
47
|
+
borderWidth: { type: [Number, String], default: 'auto' },
|
|
48
|
+
borderColor: { type: String, default: 'auto' },
|
|
49
|
+
|
|
50
|
+
// clip or mask
|
|
51
|
+
renderMode: { type: String, default: 'clip' },
|
|
52
|
+
},
|
|
53
|
+
data() {
|
|
54
|
+
return {
|
|
55
|
+
dimensions: {
|
|
56
|
+
width: 0,
|
|
57
|
+
height: 0,
|
|
58
|
+
},
|
|
59
|
+
computedRadius: null,
|
|
60
|
+
computedBorderWidth: 0,
|
|
61
|
+
computedBorderColor: null,
|
|
62
|
+
resizeObserver: null,
|
|
63
|
+
|
|
64
|
+
// flag that we've detected auto styles
|
|
65
|
+
autoStylesDetected: false,
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
computed: {
|
|
69
|
+
smoothCornersPath() {
|
|
70
|
+
if (
|
|
71
|
+
!this.smoothingEnabled ||
|
|
72
|
+
this.dimensions.width <= 0 ||
|
|
73
|
+
this.dimensions.height <= 0
|
|
74
|
+
) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return getSmoothCornersPath({
|
|
79
|
+
width: this.dimensions.width,
|
|
80
|
+
height: this.dimensions.height,
|
|
81
|
+
radius: this.computedRadius || this.radius,
|
|
82
|
+
smoothing: this.smoothing,
|
|
83
|
+
borderWidth: 0,
|
|
84
|
+
minRadius: this.minRadius,
|
|
85
|
+
maxRadius: this.maxRadius,
|
|
86
|
+
preserveSmoothing: true,
|
|
87
|
+
renderMode: this.renderMode,
|
|
88
|
+
});
|
|
89
|
+
},
|
|
90
|
+
borderMaskPath() {
|
|
91
|
+
if (
|
|
92
|
+
!this.smoothingEnabled ||
|
|
93
|
+
this.dimensions.width <= 0 ||
|
|
94
|
+
this.dimensions.height <= 0 ||
|
|
95
|
+
this.computedBorderWidth <= 0
|
|
96
|
+
) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return getBorderMaskClipPath({
|
|
101
|
+
width: this.dimensions.width,
|
|
102
|
+
height: this.dimensions.height,
|
|
103
|
+
radius: this.computedRadius || this.radius,
|
|
104
|
+
smoothing: this.smoothing,
|
|
105
|
+
borderWidth: this.computedBorderWidth,
|
|
106
|
+
minRadius: this.minRadius,
|
|
107
|
+
maxRadius: this.maxRadius,
|
|
108
|
+
preserveSmoothing: true,
|
|
109
|
+
renderMode: this.renderMode,
|
|
110
|
+
});
|
|
111
|
+
},
|
|
112
|
+
hasBorder() {
|
|
113
|
+
return !!this.borderMaskPath;
|
|
114
|
+
},
|
|
115
|
+
overrideStyles() {
|
|
116
|
+
if (!this.smoothingEnabled || !this.autoStylesDetected) return {};
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
borderRadius: 'unset',
|
|
120
|
+
borderTopLeftRadius: 'unset',
|
|
121
|
+
borderTopRightRadius: 'unset',
|
|
122
|
+
borderBottomRightRadius: 'unset',
|
|
123
|
+
borderBottomLeftRadius: 'unset',
|
|
124
|
+
border: 'unset',
|
|
125
|
+
borderWidth: 'unset',
|
|
126
|
+
borderStyle: 'unset',
|
|
127
|
+
borderColor: 'unset',
|
|
128
|
+
};
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
mounted() {
|
|
132
|
+
this.$nextTick(() => {
|
|
133
|
+
if (!this.$refs.el) return;
|
|
134
|
+
if (!this.smoothingEnabled) return;
|
|
135
|
+
|
|
136
|
+
this.detectAutoValues();
|
|
137
|
+
this.updateDimensions();
|
|
138
|
+
this.setupResizeObserver();
|
|
139
|
+
});
|
|
140
|
+
},
|
|
141
|
+
beforeUnmount() {
|
|
142
|
+
if (this.resizeObserver) {
|
|
143
|
+
this.resizeObserver.disconnect();
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
methods: {
|
|
147
|
+
parseCssDimension(value) {
|
|
148
|
+
if (typeof value === 'string' && value.length > 0) {
|
|
149
|
+
value = value.trim().split(/\s+/)[0];
|
|
150
|
+
|
|
151
|
+
if (value.endsWith('px')) return parseFloat(value);
|
|
152
|
+
if (value.endsWith('rem')) {
|
|
153
|
+
return (
|
|
154
|
+
parseFloat(value) *
|
|
155
|
+
parseFloat(getComputedStyle(document.documentElement).fontSize)
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
if (value.endsWith('em')) {
|
|
159
|
+
return parseFloat(value) * parseFloat(getComputedStyle(this.$refs.el).fontSize);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return value;
|
|
164
|
+
},
|
|
165
|
+
detectAutoValues() {
|
|
166
|
+
if (!this.$refs.el) return;
|
|
167
|
+
|
|
168
|
+
const cs = getComputedStyle(this.$refs.el);
|
|
169
|
+
|
|
170
|
+
// auto radius detection
|
|
171
|
+
if (this.radius === 'auto') {
|
|
172
|
+
const radiusTopLeft = cs.getPropertyValue('border-top-left-radius') || '0';
|
|
173
|
+
const radiusTopRight = cs.getPropertyValue('border-top-right-radius') || '0';
|
|
174
|
+
const radiusBottomRight = cs.getPropertyValue('border-bottom-right-radius') || '0';
|
|
175
|
+
const radiusBottomLeft = cs.getPropertyValue('border-bottom-left-radius') || '0';
|
|
176
|
+
|
|
177
|
+
this.computedRadius = [
|
|
178
|
+
radiusTopLeft,
|
|
179
|
+
radiusTopRight,
|
|
180
|
+
radiusBottomRight,
|
|
181
|
+
radiusBottomLeft,
|
|
182
|
+
].map(r => this.parseCssDimension(r));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// auto border width detection
|
|
186
|
+
if (this.borderWidth === 'auto') {
|
|
187
|
+
// try border-width first, fallback to border-top-width
|
|
188
|
+
let borderWidth = cs.getPropertyValue('border-width') || '0';
|
|
189
|
+
if (borderWidth === '0' || borderWidth === '0px') {
|
|
190
|
+
borderWidth = cs.getPropertyValue('border-top-width') || '0';
|
|
191
|
+
}
|
|
192
|
+
this.computedBorderWidth = this.parseCssDimension(borderWidth);
|
|
193
|
+
} else {
|
|
194
|
+
this.computedBorderWidth = parseFloat(this.borderWidth) || 0;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// auto border color detection
|
|
198
|
+
if (this.borderColor === 'auto') {
|
|
199
|
+
this.computedBorderColor = cs.getPropertyValue('border-color') || 'transparent';
|
|
200
|
+
} else {
|
|
201
|
+
this.computedBorderColor = this.borderColor;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// flag that we've detected styles, now we can apply overrides
|
|
205
|
+
this.autoStylesDetected = true;
|
|
206
|
+
},
|
|
207
|
+
updateDimensions() {
|
|
208
|
+
if (!this.$refs.el) return;
|
|
209
|
+
|
|
210
|
+
const rect = this.$refs.el.getBoundingClientRect();
|
|
211
|
+
this.dimensions.width = rect.width;
|
|
212
|
+
this.dimensions.height = rect.height;
|
|
213
|
+
},
|
|
214
|
+
setupResizeObserver() {
|
|
215
|
+
if (!this.$refs.el) return;
|
|
216
|
+
|
|
217
|
+
this.resizeObserver = new ResizeObserver(entries => {
|
|
218
|
+
for (const entry of entries) {
|
|
219
|
+
this.dimensions.width = entry.contentRect.width;
|
|
220
|
+
this.dimensions.height = entry.contentRect.height;
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
this.resizeObserver.observe(this.$refs.el);
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
</script>
|
|
229
|
+
|
|
230
|
+
<!-- <style lang="scss" scoped>
|
|
231
|
+
// @use '@/styles/include.scss' as *;
|
|
232
|
+
@use '../styles/px.scss' as *;
|
|
233
|
+
</style> -->
|
|
@@ -190,7 +190,7 @@ export default {
|
|
|
190
190
|
$popup: get(
|
|
191
191
|
'controls.popup',
|
|
192
192
|
(
|
|
193
|
-
background-color:
|
|
193
|
+
background-color: site-var(page-bg),
|
|
194
194
|
border-radius: get('controls.base.border-radius'),
|
|
195
195
|
box-shadow: shadow(15),
|
|
196
196
|
font-size: get('controls.base.font-size'),
|
package/components/PxMenu.vue
CHANGED
|
@@ -198,8 +198,8 @@ export {
|
|
|
198
198
|
|
|
199
199
|
.px-menu {
|
|
200
200
|
font-family: get('font-family');
|
|
201
|
-
background-color:
|
|
202
|
-
color:
|
|
201
|
+
background-color: site-var(page-bg);
|
|
202
|
+
color: site-var(page-fg);
|
|
203
203
|
border-radius: get('controls.border-radius');
|
|
204
204
|
overflow: hidden;
|
|
205
205
|
display: flex;
|
|
@@ -121,15 +121,15 @@ function mouseMove(e) {
|
|
|
121
121
|
<style lang="scss">
|
|
122
122
|
@use '../styles/px.scss' as *;
|
|
123
123
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
124
|
+
@at-root :root {
|
|
125
|
+
--px-scrubber-height: 4px;
|
|
126
|
+
--px-scrubber-bg: #{clr(accent, $alpha: 0.1)};
|
|
127
|
+
--px-scrubber-padding: 1em 0;
|
|
128
|
+
--px-scrubber-progress-bg: #{clr(accent, 1)};
|
|
129
|
+
--px-scrubber-preview-bg: #{clr(accent, $alpha: 0.33)};
|
|
130
|
+
}
|
|
132
131
|
|
|
132
|
+
.px-scrubber {
|
|
133
133
|
width: 100%;
|
|
134
134
|
padding: var(--px-scrubber-padding);
|
|
135
135
|
cursor: pointer;
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
*/
|
|
48
48
|
|
|
49
49
|
import { computed, onMounted, onUnmounted, nextTick, reactive, watch } from 'vue';
|
|
50
|
-
import {
|
|
50
|
+
import { getSmoothCornersPath, getBorderMaskClipPath } from '../utils/smootherCorners';
|
|
51
51
|
|
|
52
52
|
function useCornerSmoothing(elementRef, options = {}) {
|
|
53
53
|
const dimensions = reactive({ width: 0, height: 0 });
|
|
@@ -55,7 +55,7 @@ function useCornerSmoothing(elementRef, options = {}) {
|
|
|
55
55
|
let resizeObserver = null;
|
|
56
56
|
|
|
57
57
|
const config = reactive({
|
|
58
|
-
radius:
|
|
58
|
+
radius: 'auto',
|
|
59
59
|
smoothing: 0.8,
|
|
60
60
|
borderWidth: 0,
|
|
61
61
|
minRadius: 0,
|
|
@@ -66,127 +66,59 @@ function useCornerSmoothing(elementRef, options = {}) {
|
|
|
66
66
|
});
|
|
67
67
|
|
|
68
68
|
/**
|
|
69
|
-
*
|
|
69
|
+
* Computed clip-path value for the outer container (full size, ignoring borderWidth)
|
|
70
70
|
*/
|
|
71
|
-
|
|
72
|
-
if (
|
|
73
|
-
const percent = parseFloat(value) / 100;
|
|
74
|
-
return reference * percent;
|
|
75
|
-
}
|
|
76
|
-
return parseFloat(value) || 0;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* expand radius array using css border-radius rules
|
|
81
|
-
*/
|
|
82
|
-
function expandRadiusArray(radius) {
|
|
83
|
-
if (!Array.isArray(radius)) {
|
|
84
|
-
return [radius, radius, radius, radius];
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const [tl, tr = tl, br = tl, bl = tr] = radius;
|
|
88
|
-
return [tl, tr, br, bl];
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* calculate corner radii with percentage support and clamping
|
|
93
|
-
*/
|
|
94
|
-
function calculateCornerRadii(radius, width, height, minRadius, maxRadius) {
|
|
95
|
-
const reference = Math.max(width, height);
|
|
96
|
-
const expanded = expandRadiusArray(radius);
|
|
71
|
+
const clipPath = computed(() => {
|
|
72
|
+
if (dimensions.width <= 0 || dimensions.height <= 0) return null;
|
|
97
73
|
|
|
98
|
-
return
|
|
99
|
-
|
|
100
|
-
|
|
74
|
+
return getSmoothCornersPath({
|
|
75
|
+
width: dimensions.width,
|
|
76
|
+
height: dimensions.height,
|
|
77
|
+
radius: config.radius,
|
|
78
|
+
smoothing: config.smoothing,
|
|
79
|
+
borderWidth: 0, // always 0 for outer container
|
|
80
|
+
minRadius: config.minRadius,
|
|
81
|
+
maxRadius: config.maxRadius,
|
|
82
|
+
preserveSmoothing: config.preserveSmoothing,
|
|
101
83
|
});
|
|
102
|
-
}
|
|
84
|
+
});
|
|
103
85
|
|
|
104
86
|
/**
|
|
105
|
-
*
|
|
87
|
+
* Computed clip-path value for inner content (accounting for borderWidth to create border space)
|
|
106
88
|
*/
|
|
107
|
-
|
|
108
|
-
if (width <= 0 || height <= 0) return
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
preserveSmoothing,
|
|
123
|
-
});
|
|
124
|
-
} else {
|
|
125
|
-
// use individual corner API for different corners
|
|
126
|
-
return getSvgPath({
|
|
127
|
-
width,
|
|
128
|
-
height,
|
|
129
|
-
cornerRadius: Math.max(tl, tr, br, bl), // fallback base radius
|
|
130
|
-
cornerSmoothing: smoothing,
|
|
131
|
-
preserveSmoothing,
|
|
132
|
-
topLeftCornerRadius: tl,
|
|
133
|
-
topRightCornerRadius: tr,
|
|
134
|
-
bottomRightCornerRadius: br,
|
|
135
|
-
bottomLeftCornerRadius: bl,
|
|
136
|
-
});
|
|
137
|
-
}
|
|
138
|
-
}
|
|
89
|
+
const innerClipPath = computed(() => {
|
|
90
|
+
if (dimensions.width <= 0 || dimensions.height <= 0) return null;
|
|
91
|
+
if (config.borderWidth <= 0) return clipPath.value; // no border, use full path
|
|
92
|
+
|
|
93
|
+
return getSmoothCornersPath({
|
|
94
|
+
width: dimensions.width,
|
|
95
|
+
height: dimensions.height,
|
|
96
|
+
radius: config.radius,
|
|
97
|
+
smoothing: config.smoothing,
|
|
98
|
+
borderWidth: config.borderWidth, // reduce for border space
|
|
99
|
+
minRadius: config.minRadius,
|
|
100
|
+
maxRadius: config.maxRadius,
|
|
101
|
+
preserveSmoothing: config.preserveSmoothing,
|
|
102
|
+
});
|
|
103
|
+
});
|
|
139
104
|
|
|
140
105
|
/**
|
|
141
|
-
* Computed clip-path
|
|
106
|
+
* Computed border mask clip-path (full rectangle with smooth hole cut out)
|
|
142
107
|
*/
|
|
143
|
-
const
|
|
108
|
+
const borderMaskClipPath = computed(() => {
|
|
144
109
|
if (dimensions.width <= 0 || dimensions.height <= 0) return null;
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
config.
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
config.maxRadius,
|
|
158
|
-
);
|
|
159
|
-
|
|
160
|
-
const path = generateSquirclePath(
|
|
161
|
-
adjustedWidth,
|
|
162
|
-
adjustedHeight,
|
|
163
|
-
radii,
|
|
164
|
-
config.smoothing,
|
|
165
|
-
config.preserveSmoothing,
|
|
166
|
-
);
|
|
167
|
-
|
|
168
|
-
if (!path) return null;
|
|
169
|
-
|
|
170
|
-
// offset path if border width is specified
|
|
171
|
-
const offsetX = config.borderWidth;
|
|
172
|
-
const offsetY = config.borderWidth;
|
|
173
|
-
|
|
174
|
-
if (offsetX > 0 || offsetY > 0) {
|
|
175
|
-
// transform path to account for border offset
|
|
176
|
-
const translatedPath = path
|
|
177
|
-
.replace(/([ML])\s*([0-9.]+)\s+([0-9.]+)/g, (_, command, x, y) => {
|
|
178
|
-
return `${command} ${parseFloat(x) + offsetX} ${parseFloat(y) + offsetY}`;
|
|
179
|
-
})
|
|
180
|
-
.replace(
|
|
181
|
-
/([C])\s*([0-9.]+)\s+([0-9.]+)\s+([0-9.]+)\s+([0-9.]+)\s+([0-9.]+)\s+([0-9.]+)/g,
|
|
182
|
-
(_, command, x1, y1, x2, y2, x3, y3) => {
|
|
183
|
-
return `${command} ${parseFloat(x1) + offsetX} ${parseFloat(y1) + offsetY} ${parseFloat(x2) + offsetX} ${parseFloat(y2) + offsetY} ${parseFloat(x3) + offsetX} ${parseFloat(y3) + offsetY}`;
|
|
184
|
-
},
|
|
185
|
-
);
|
|
186
|
-
return `path('${translatedPath}')`;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
return `path('${path}')`;
|
|
110
|
+
if (config.borderWidth <= 0) return null;
|
|
111
|
+
|
|
112
|
+
return getBorderMaskClipPath({
|
|
113
|
+
width: dimensions.width,
|
|
114
|
+
height: dimensions.height,
|
|
115
|
+
radius: config.radius,
|
|
116
|
+
smoothing: config.smoothing,
|
|
117
|
+
borderWidth: config.borderWidth,
|
|
118
|
+
minRadius: config.minRadius,
|
|
119
|
+
maxRadius: config.maxRadius,
|
|
120
|
+
preserveSmoothing: config.preserveSmoothing,
|
|
121
|
+
});
|
|
190
122
|
});
|
|
191
123
|
|
|
192
124
|
/**
|
|
@@ -220,6 +152,61 @@ function useCornerSmoothing(elementRef, options = {}) {
|
|
|
220
152
|
nextTick(() => {
|
|
221
153
|
if (!elementRef.value) return;
|
|
222
154
|
|
|
155
|
+
function parseCssDimension(value) {
|
|
156
|
+
if (typeof value === 'string' && value.length > 0) {
|
|
157
|
+
value = value.trim().split(/\s+/)[0];
|
|
158
|
+
|
|
159
|
+
if (value.endsWith('px')) return parseFloat(value);
|
|
160
|
+
if (value.endsWith('rem')) {
|
|
161
|
+
return (
|
|
162
|
+
parseFloat(value) *
|
|
163
|
+
parseFloat(getComputedStyle(document.documentElement).fontSize)
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
if (value.endsWith('em')) {
|
|
167
|
+
return (
|
|
168
|
+
parseFloat(value) *
|
|
169
|
+
parseFloat(getComputedStyle(elementRef.value).fontSize)
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return value;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let cs = null;
|
|
178
|
+
|
|
179
|
+
// auto radius
|
|
180
|
+
|
|
181
|
+
if (config.radius === 'auto') {
|
|
182
|
+
// auto-detect radius based on current styles
|
|
183
|
+
cs = getComputedStyle(elementRef.value);
|
|
184
|
+
const radiusTopLeft = cs.getPropertyValue('border-top-left-radius') || '0';
|
|
185
|
+
const radiusTopRight = cs.getPropertyValue('border-top-right-radius') || '0';
|
|
186
|
+
const radiusBottomRight = cs.getPropertyValue('border-bottom-right-radius') || '0';
|
|
187
|
+
const radiusBottomLeft = cs.getPropertyValue('border-bottom-left-radius') || '0';
|
|
188
|
+
config.radius = [
|
|
189
|
+
radiusTopLeft,
|
|
190
|
+
radiusTopRight,
|
|
191
|
+
radiusBottomRight,
|
|
192
|
+
radiusBottomLeft,
|
|
193
|
+
].map(r => {
|
|
194
|
+
return parseCssDimension(r, true);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// auto border width
|
|
199
|
+
if (config.borderWidth === 'auto') {
|
|
200
|
+
cs = cs || getComputedStyle(elementRef.value);
|
|
201
|
+
const borderWidth = cs.getPropertyValue('border-thickness') || '0';
|
|
202
|
+
|
|
203
|
+
console.log('bw' + cs.getPropertyValue('border-thickness'));
|
|
204
|
+
|
|
205
|
+
config.borderWidth = parseCssDimension(borderWidth, true);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
console.log(config.borderWidth);
|
|
209
|
+
|
|
223
210
|
// initial dimension update
|
|
224
211
|
updateDimensions();
|
|
225
212
|
|
|
@@ -255,6 +242,8 @@ function useCornerSmoothing(elementRef, options = {}) {
|
|
|
255
242
|
|
|
256
243
|
return {
|
|
257
244
|
clipPath,
|
|
245
|
+
innerClipPath,
|
|
246
|
+
borderMaskClipPath,
|
|
258
247
|
updateConfig,
|
|
259
248
|
forceUpdate,
|
|
260
249
|
};
|
package/package.json
CHANGED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Reference:
|
|
2
|
+
// https://storybook.js.org/blog/storybook-7-docs/
|
|
3
|
+
// https://storybook.js.org/docs/7.5/writing-docs/autodocs
|
|
4
|
+
|
|
5
|
+
import PxCorners from '@/components/PxCorners.vue';
|
|
6
|
+
import { extractArgTypes } from '@/storybook/sb-utils.js';
|
|
7
|
+
|
|
8
|
+
export default {
|
|
9
|
+
component: PxCorners,
|
|
10
|
+
// decorators: [ ... ],
|
|
11
|
+
// parameters: { ... },
|
|
12
|
+
|
|
13
|
+
// https://storybook.js.org/docs/7.0/vue/essentials/controls#annotation
|
|
14
|
+
// https://storybook.js.org/docs/7.0/vue/api/argtypes#available-properties
|
|
15
|
+
argTypes: {
|
|
16
|
+
...extractArgTypes(PxCorners),
|
|
17
|
+
// someProp: { type: 'select', options: ['one', 'two', 'three'] }
|
|
18
|
+
},
|
|
19
|
+
args: {
|
|
20
|
+
// label: 'Hello',
|
|
21
|
+
// onClick: () => {}),
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const Compare = {
|
|
26
|
+
// name: 'Story Name',
|
|
27
|
+
// parameters: {
|
|
28
|
+
// customCSS: `.myclass { border: 4px solid red; }`,
|
|
29
|
+
// },
|
|
30
|
+
render: (args, { argTypes }) => ({
|
|
31
|
+
components: { PxCorners },
|
|
32
|
+
setup() {
|
|
33
|
+
return { args };
|
|
34
|
+
},
|
|
35
|
+
template: `
|
|
36
|
+
<div class="story-flex" style=" position: relative;">
|
|
37
|
+
<div v-bind="args" class="story-bg-blue" style="border-radius: 50px; width: 200px; height: 200px;" />
|
|
38
|
+
<PxCorners v-bind="args" class="story-bg-orange" :radius="50" style="width: 200px; height: 200px;" />
|
|
39
|
+
</div>
|
|
40
|
+
`,
|
|
41
|
+
}),
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const Basic = {
|
|
45
|
+
// name: 'Story Name',
|
|
46
|
+
// parameters: {
|
|
47
|
+
// customCSS: `.myclass { border: 4px solid red; }`,
|
|
48
|
+
// },
|
|
49
|
+
render: (args, { argTypes }) => ({
|
|
50
|
+
components: { PxCorners },
|
|
51
|
+
setup() {
|
|
52
|
+
return { args };
|
|
53
|
+
},
|
|
54
|
+
template: `<PxCorners v-bind="args" class="story-size-md story-bg-orange" :radius="40" :borderWidth="4" borderColor="black" />`,
|
|
55
|
+
}),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const Slot = {
|
|
59
|
+
// name: 'Story Name',
|
|
60
|
+
// parameters: {
|
|
61
|
+
// customCSS: `.myclass { border: 4px solid red; }`,
|
|
62
|
+
// },
|
|
63
|
+
render: (args, { argTypes }) => ({
|
|
64
|
+
components: { PxCorners },
|
|
65
|
+
setup() {
|
|
66
|
+
return { args };
|
|
67
|
+
},
|
|
68
|
+
template: `<PxCorners v-bind="args" class="story-size-md story-bg-blue story-center" :radius="40" > Slot content </PxCorners>`,
|
|
69
|
+
}),
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const SlotWithBorder = {
|
|
73
|
+
// name: 'Story Name',
|
|
74
|
+
// parameters: {
|
|
75
|
+
// customCSS: `.myclass { border: 4px solid red; }`,
|
|
76
|
+
// },
|
|
77
|
+
render: (args, { argTypes }) => ({
|
|
78
|
+
components: { PxCorners },
|
|
79
|
+
setup() {
|
|
80
|
+
return { args };
|
|
81
|
+
},
|
|
82
|
+
template: `<PxCorners v-bind="args" class="story-size-md story-bg-blue story-center" :radius="40" :borderWidth="4"> Slot content </PxCorners>`,
|
|
83
|
+
}),
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const HoverWithBorder = {
|
|
87
|
+
// name: 'Story Name',
|
|
88
|
+
// parameters: {
|
|
89
|
+
// customCSS: `.myclass { border: 4px solid red; }`,
|
|
90
|
+
// },
|
|
91
|
+
render: (args, { argTypes }) => ({
|
|
92
|
+
components: { PxCorners },
|
|
93
|
+
setup() {
|
|
94
|
+
return { args };
|
|
95
|
+
},
|
|
96
|
+
template: `<PxCorners v-bind="args" class="story-size-md story-bg-orange story-hover-bg-blue story-center" :radius="40" :borderWidth="10"> Slot content </PxCorners>`,
|
|
97
|
+
}),
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export const AutoFromStyle = {
|
|
101
|
+
// name: 'Story Name',
|
|
102
|
+
// parameters: {
|
|
103
|
+
// customCSS: `.myclass { border: 4px solid red; }`,
|
|
104
|
+
// },
|
|
105
|
+
render: (args, { argTypes }) => ({
|
|
106
|
+
components: { PxCorners },
|
|
107
|
+
setup() {
|
|
108
|
+
return { args };
|
|
109
|
+
},
|
|
110
|
+
template: `<PxCorners :smoothingEnabled="true" v-bind="args" class="story-size-md story-bg-orange" style="border-radius: 40px; border: 4px solid black;" />`,
|
|
111
|
+
}),
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export const AutoPercent = {
|
|
115
|
+
// name: 'Story Name',
|
|
116
|
+
// parameters: {
|
|
117
|
+
// customCSS: `.myclass { border: 4px solid red; }`,
|
|
118
|
+
// },
|
|
119
|
+
render: (args, { argTypes }) => ({
|
|
120
|
+
components: { PxCorners },
|
|
121
|
+
setup() {
|
|
122
|
+
return { args };
|
|
123
|
+
},
|
|
124
|
+
template: `
|
|
125
|
+
<div class="story-flex" style=" position: relative;">
|
|
126
|
+
<PxCorners :smoothingEnabled="true" v-bind="args" class="story-bg-orange story-center" style="width: 300px; height: 200px; border-radius: 20%;">
|
|
127
|
+
Style 20%
|
|
128
|
+
</PxCorners>
|
|
129
|
+
<PxCorners :smoothingEnabled="true" v-bind="args" class="story-bg-orange story-center" style="width: 300px; height: 200px;" :radius="60">
|
|
130
|
+
:radius 60px
|
|
131
|
+
</PxCorners>
|
|
132
|
+
<div class="story-bg-orange story-center" style="width: 300px; height: 200px; border-radius: 20%;">
|
|
133
|
+
Div 20%
|
|
134
|
+
</div>
|
|
135
|
+
<div class="story-bg-orange story-center" style="width: 300px; height: 200px; border-radius: 60px;">
|
|
136
|
+
Div 60px
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
`,
|
|
140
|
+
}),
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export const Disabled = {
|
|
144
|
+
// name: 'Story Name',
|
|
145
|
+
// parameters: {
|
|
146
|
+
// customCSS: `.myclass { border: 4px solid red; }`,
|
|
147
|
+
// },
|
|
148
|
+
render: (args, { argTypes }) => ({
|
|
149
|
+
components: { PxCorners },
|
|
150
|
+
setup() {
|
|
151
|
+
return { args };
|
|
152
|
+
},
|
|
153
|
+
template: `<PxCorners :smoothingEnabled="false" v-bind="args" class="story-size-md story-bg-orange" :radius="40" :borderWidth="4" borderColor="black" />`,
|
|
154
|
+
}),
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
export const DisabledAutoFromStyle = {
|
|
158
|
+
// name: 'Story Name',
|
|
159
|
+
// parameters: {
|
|
160
|
+
// customCSS: `.myclass { border: 4px solid red; }`,
|
|
161
|
+
// },
|
|
162
|
+
render: (args, { argTypes }) => ({
|
|
163
|
+
components: { PxCorners },
|
|
164
|
+
setup() {
|
|
165
|
+
return { args };
|
|
166
|
+
},
|
|
167
|
+
template: `<PxCorners :smoothingEnabled="false" v-bind="args" class="story-size-md story-bg-orange" style="border-radius: 40px; border: 4px solid black;" />`,
|
|
168
|
+
}),
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
export const BasicMask = {
|
|
172
|
+
// name: 'Story Name',
|
|
173
|
+
// parameters: {
|
|
174
|
+
// customCSS: `.myclass { border: 4px solid red; }`,
|
|
175
|
+
// },
|
|
176
|
+
render: (args, { argTypes }) => ({
|
|
177
|
+
components: { PxCorners },
|
|
178
|
+
setup() {
|
|
179
|
+
return { args };
|
|
180
|
+
},
|
|
181
|
+
template: `<PxCorners v-bind="args" renderMode="mask" class="story-size-md story-bg-orange" :radius="40" :borderWidth="4" borderColor="black" />`,
|
|
182
|
+
}),
|
|
183
|
+
};
|
|
184
|
+
// export const Advanced = {
|
|
185
|
+
// ...Basic,
|
|
186
|
+
// // args: { ... },
|
|
187
|
+
// };
|
package/storybook/sb-styles.scss
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
@use '@thinkpixellab-public/px-styles/all' as *;
|
|
2
|
+
@use 'sass:color' as color;
|
|
2
3
|
|
|
3
4
|
// Update this to test font inheritance
|
|
4
5
|
.sbdocs {
|
|
@@ -91,11 +92,8 @@ body {
|
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
.story-gradient {
|
|
94
|
-
background-image:
|
|
95
|
-
|
|
96
|
-
rgba(black, 0.1) 0 20px,
|
|
97
|
-
rgba(black, 0) 20px 40px
|
|
98
|
-
),
|
|
95
|
+
background-image:
|
|
96
|
+
repeating-linear-gradient(-45deg, rgba(black, 0.1) 0 20px, rgba(black, 0) 20px 40px),
|
|
99
97
|
linear-gradient(135deg, tomato, purple);
|
|
100
98
|
width: 100%;
|
|
101
99
|
height: 100%;
|
|
@@ -145,6 +143,13 @@ body {
|
|
|
145
143
|
background-color: gold;
|
|
146
144
|
}
|
|
147
145
|
|
|
146
|
+
.story-hover-bg-blue {
|
|
147
|
+
cursor: pointer;
|
|
148
|
+
}
|
|
149
|
+
.story-hover-bg-blue:hover {
|
|
150
|
+
background-color: dodgerblue;
|
|
151
|
+
}
|
|
152
|
+
|
|
148
153
|
.story-size-xs {
|
|
149
154
|
width: 2em;
|
|
150
155
|
height: 2em;
|
|
@@ -176,7 +181,7 @@ body {
|
|
|
176
181
|
font-family: sans-serif,
|
|
177
182
|
padding: 0.5em,
|
|
178
183
|
font-size: 14px,
|
|
179
|
-
background-color: mix(dodgerblue, white, 15%),
|
|
184
|
+
background-color: color.mix(dodgerblue, white, 15%),
|
|
180
185
|
)
|
|
181
186
|
);
|
|
182
187
|
}
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* smootherCorners.js
|
|
3
|
+
*
|
|
4
|
+
* Utility functions for creating smooth, squircle-like corners using CSS clip-path.
|
|
5
|
+
* Based on Figma's corner smoothing approach.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* ```js
|
|
9
|
+
* import { getSmoothCornersPath } from '@thinkpixellab-public/px-vue/utils/smootherCorners'
|
|
10
|
+
*
|
|
11
|
+
* const clipPath = getSmoothCornersPath({
|
|
12
|
+
* width: 200,
|
|
13
|
+
* height: 100,
|
|
14
|
+
* radius: 16,
|
|
15
|
+
* smoothing: 0.8
|
|
16
|
+
* })
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { getSvgPath } from 'figma-squircle';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parse percentage string to number
|
|
24
|
+
* @param {string|number} value - Value to parse
|
|
25
|
+
* @param {number} reference - Reference value for percentage calculation
|
|
26
|
+
* @returns {number}
|
|
27
|
+
*/
|
|
28
|
+
export function parsePercentage(value, reference) {
|
|
29
|
+
if (typeof value === 'string' && value.endsWith('%')) {
|
|
30
|
+
const percent = parseFloat(value) / 100;
|
|
31
|
+
return reference * percent;
|
|
32
|
+
}
|
|
33
|
+
return parseFloat(value) || 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Expand radius array using CSS border-radius rules
|
|
38
|
+
* @param {number|Array} radius - Single radius or array of radii
|
|
39
|
+
* @returns {Array<number>} Array of four corner radii [tl, tr, br, bl]
|
|
40
|
+
*/
|
|
41
|
+
export function expandRadiusArray(radius) {
|
|
42
|
+
if (!Array.isArray(radius)) {
|
|
43
|
+
return [radius, radius, radius, radius];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const [tl, tr = tl, br = tl, bl = tr] = radius;
|
|
47
|
+
return [tl, tr, br, bl];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Calculate corner radii with percentage support and clamping
|
|
52
|
+
* @param {number|Array|string} radius - Radius value(s)
|
|
53
|
+
* @param {number} width - Element width
|
|
54
|
+
* @param {number} height - Element height
|
|
55
|
+
* @param {number} minRadius - Minimum radius constraint
|
|
56
|
+
* @param {number} maxRadius - Maximum radius constraint
|
|
57
|
+
* @returns {Array<number>} Calculated radii for each corner
|
|
58
|
+
*/
|
|
59
|
+
export function calculateCornerRadii(radius, width, height, minRadius = 0, maxRadius = Infinity) {
|
|
60
|
+
const reference = Math.max(width, height);
|
|
61
|
+
const expanded = expandRadiusArray(radius);
|
|
62
|
+
|
|
63
|
+
return expanded.map(r => {
|
|
64
|
+
const parsed = parsePercentage(r, reference);
|
|
65
|
+
return Math.max(minRadius, Math.min(maxRadius, parsed));
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Generate squircle path using figma-squircle library
|
|
71
|
+
* @param {number} width - Path width
|
|
72
|
+
* @param {number} height - Path height
|
|
73
|
+
* @param {Array<number>} radii - Corner radii [tl, tr, br, bl]
|
|
74
|
+
* @param {number} smoothing - Smoothing amount (0-1)
|
|
75
|
+
* @param {boolean} preserveSmoothing - Maintain smoothing at larger radii
|
|
76
|
+
* @returns {string} SVG path string
|
|
77
|
+
*/
|
|
78
|
+
export function generateSquirclePath(
|
|
79
|
+
width,
|
|
80
|
+
height,
|
|
81
|
+
radii,
|
|
82
|
+
smoothing = 0.8,
|
|
83
|
+
preserveSmoothing = true,
|
|
84
|
+
) {
|
|
85
|
+
if (width <= 0 || height <= 0) return '';
|
|
86
|
+
|
|
87
|
+
const [tl, tr, br, bl] = radii;
|
|
88
|
+
|
|
89
|
+
// check if all corners are the same (uniform)
|
|
90
|
+
const isUniform = tl === tr && tr === br && br === bl;
|
|
91
|
+
|
|
92
|
+
if (isUniform) {
|
|
93
|
+
// use simple API for uniform corners
|
|
94
|
+
return getSvgPath({
|
|
95
|
+
width,
|
|
96
|
+
height,
|
|
97
|
+
cornerRadius: tl,
|
|
98
|
+
cornerSmoothing: smoothing,
|
|
99
|
+
preserveSmoothing,
|
|
100
|
+
});
|
|
101
|
+
} else {
|
|
102
|
+
// use individual corner API for different corners
|
|
103
|
+
return getSvgPath({
|
|
104
|
+
width,
|
|
105
|
+
height,
|
|
106
|
+
cornerRadius: Math.max(tl, tr, br, bl), // fallback base radius
|
|
107
|
+
cornerSmoothing: smoothing,
|
|
108
|
+
preserveSmoothing,
|
|
109
|
+
topLeftCornerRadius: tl,
|
|
110
|
+
topRightCornerRadius: tr,
|
|
111
|
+
bottomRightCornerRadius: br,
|
|
112
|
+
bottomLeftCornerRadius: bl,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Apply border offset to SVG path
|
|
119
|
+
* @param {string} path - SVG path string
|
|
120
|
+
* @param {number} offsetX - X offset
|
|
121
|
+
* @param {number} offsetY - Y offset
|
|
122
|
+
* @returns {string} Transformed path string
|
|
123
|
+
*/
|
|
124
|
+
export function applyPathOffset(path, offsetX, offsetY) {
|
|
125
|
+
if (!path || (offsetX === 0 && offsetY === 0)) return path;
|
|
126
|
+
|
|
127
|
+
// transform path to account for offset
|
|
128
|
+
const translatedPath = path
|
|
129
|
+
.replace(/([ML])\s*([0-9.]+)\s+([0-9.]+)/g, (_, command, x, y) => {
|
|
130
|
+
return `${command} ${parseFloat(x) + offsetX} ${parseFloat(y) + offsetY}`;
|
|
131
|
+
})
|
|
132
|
+
.replace(
|
|
133
|
+
/([C])\s*([0-9.]+)\s+([0-9.]+)\s+([0-9.]+)\s+([0-9.]+)\s+([0-9.]+)\s+([0-9.]+)/g,
|
|
134
|
+
(_, command, x1, y1, x2, y2, x3, y3) => {
|
|
135
|
+
return `${command} ${parseFloat(x1) + offsetX} ${parseFloat(y1) + offsetY} ${parseFloat(x2) + offsetX} ${parseFloat(y2) + offsetY} ${parseFloat(x3) + offsetX} ${parseFloat(y3) + offsetY}`;
|
|
136
|
+
},
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
return translatedPath;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Generate smooth corners clip-path value or SVG mask
|
|
144
|
+
* @param {Object} options - Configuration options
|
|
145
|
+
* @param {number} options.width - Element width
|
|
146
|
+
* @param {number} options.height - Element height
|
|
147
|
+
* @param {number|Array|string} options.radius - Corner radius (number, array, or percentage)
|
|
148
|
+
* @param {number} options.smoothing - Smoothing amount 0-1 (default: 0.8)
|
|
149
|
+
* @param {number} options.borderWidth - Border width to account for (default: 0)
|
|
150
|
+
* @param {number} options.minRadius - Min radius in px (default: 0)
|
|
151
|
+
* @param {number} options.maxRadius - Max radius in px (default: Infinity)
|
|
152
|
+
* @param {boolean} options.preserveSmoothing - Maintain smoothing at larger radii (default: true)
|
|
153
|
+
* @param {string} options.renderMode - Render mode: 'clip-path' | 'mask' (default: 'clip-path')
|
|
154
|
+
* @returns {string|null} CSS clip-path value, SVG mask data URL, or null if invalid dimensions
|
|
155
|
+
*/
|
|
156
|
+
export function getSmoothCornersPath(options = {}) {
|
|
157
|
+
const {
|
|
158
|
+
width,
|
|
159
|
+
height,
|
|
160
|
+
radius = 8,
|
|
161
|
+
smoothing = 0.8,
|
|
162
|
+
borderWidth = 0,
|
|
163
|
+
minRadius = 0,
|
|
164
|
+
maxRadius = Infinity,
|
|
165
|
+
preserveSmoothing = true,
|
|
166
|
+
renderMode = 'clip-path',
|
|
167
|
+
} = options;
|
|
168
|
+
|
|
169
|
+
if (width <= 0 || height <= 0) return null;
|
|
170
|
+
|
|
171
|
+
const adjustedWidth = Math.max(0, width - borderWidth * 2);
|
|
172
|
+
const adjustedHeight = Math.max(0, height - borderWidth * 2);
|
|
173
|
+
|
|
174
|
+
if (adjustedWidth <= 0 || adjustedHeight <= 0) return null;
|
|
175
|
+
|
|
176
|
+
// calculate corner radii with percentage support and clamping
|
|
177
|
+
const radii = calculateCornerRadii(radius, adjustedWidth, adjustedHeight, minRadius, maxRadius);
|
|
178
|
+
|
|
179
|
+
// check if all radii are 0 - no need for clipping/masking
|
|
180
|
+
if (radii.every(r => r === 0)) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// adjust radii for inner path when borderWidth is present
|
|
185
|
+
const adjustedRadii = borderWidth > 0
|
|
186
|
+
? radii.map(r => Math.max(0, r - borderWidth))
|
|
187
|
+
: radii;
|
|
188
|
+
|
|
189
|
+
const path = generateSquirclePath(
|
|
190
|
+
adjustedWidth,
|
|
191
|
+
adjustedHeight,
|
|
192
|
+
adjustedRadii,
|
|
193
|
+
smoothing,
|
|
194
|
+
preserveSmoothing,
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
if (!path) return null;
|
|
198
|
+
|
|
199
|
+
// apply border offset if needed
|
|
200
|
+
const finalPath = borderWidth > 0 ? applyPathOffset(path, borderWidth, borderWidth) : path;
|
|
201
|
+
|
|
202
|
+
// return format based on render mode
|
|
203
|
+
if (renderMode === 'mask') {
|
|
204
|
+
return createSvgMaskDataUrl(finalPath, width, height);
|
|
205
|
+
} else {
|
|
206
|
+
return `path('${finalPath}')`;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Generate border mask using CSS masks instead of complex path manipulation
|
|
212
|
+
* @param {Object} options - Configuration options
|
|
213
|
+
* @returns {Object} Object with CSS mask properties for creating border effect
|
|
214
|
+
*/
|
|
215
|
+
export function getBorderMaskStyles(options = {}) {
|
|
216
|
+
const { width, height, borderWidth = 0 } = options;
|
|
217
|
+
|
|
218
|
+
if (width <= 0 || height <= 0 || borderWidth <= 0) return null;
|
|
219
|
+
|
|
220
|
+
// Create the inner path (smaller for content area)
|
|
221
|
+
const innerPath = getSmoothCornersPath({
|
|
222
|
+
width: width - (borderWidth * 2),
|
|
223
|
+
height: height - (borderWidth * 2),
|
|
224
|
+
radius: options.radius,
|
|
225
|
+
smoothing: options.smoothing,
|
|
226
|
+
borderWidth: 0,
|
|
227
|
+
minRadius: options.minRadius,
|
|
228
|
+
maxRadius: options.maxRadius,
|
|
229
|
+
preserveSmoothing: options.preserveSmoothing,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
if (!innerPath) return null;
|
|
233
|
+
|
|
234
|
+
// Position the inner mask to create border space
|
|
235
|
+
const maskPosition = `${borderWidth}px ${borderWidth}px`;
|
|
236
|
+
const maskSize = `${width - (borderWidth * 2)}px ${height - (borderWidth * 2)}px`;
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
WebkitMask: `radial-gradient(white, white), ${innerPath}`,
|
|
240
|
+
mask: `radial-gradient(white, white), ${innerPath}`,
|
|
241
|
+
WebkitMaskComposite: 'xor',
|
|
242
|
+
maskComposite: 'subtract',
|
|
243
|
+
WebkitMaskPosition: `0 0, ${maskPosition}`,
|
|
244
|
+
maskPosition: `0 0, ${maskPosition}`,
|
|
245
|
+
WebkitMaskSize: `100% 100%, ${maskSize}`,
|
|
246
|
+
maskSize: `100% 100%, ${maskSize}`,
|
|
247
|
+
WebkitMaskRepeat: 'no-repeat, no-repeat',
|
|
248
|
+
maskRepeat: 'no-repeat, no-repeat'
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Generate border mask clip path using compound path approach or SVG mask
|
|
254
|
+
* @param {Object} options - Configuration options
|
|
255
|
+
* @param {string} options.renderMode - Render mode: 'clip-path' | 'mask' (default: 'clip-path')
|
|
256
|
+
* @returns {string|null} CSS clip-path value, SVG mask data URL, or null if invalid
|
|
257
|
+
*/
|
|
258
|
+
export function getBorderMaskClipPath(options = {}) {
|
|
259
|
+
const { width, height, borderWidth = 0, renderMode = 'clip-path' } = options;
|
|
260
|
+
|
|
261
|
+
if (width <= 0 || height <= 0 || borderWidth <= 0) return null;
|
|
262
|
+
|
|
263
|
+
// Check if the border is too large for the element
|
|
264
|
+
if (borderWidth * 2 >= width || borderWidth * 2 >= height) return null;
|
|
265
|
+
|
|
266
|
+
// Create outer rectangle path (clockwise)
|
|
267
|
+
const rectPath = `M 0 0 H ${width} V ${height} H 0 Z`;
|
|
268
|
+
|
|
269
|
+
// Generate the inner smooth path directly at the correct size and position
|
|
270
|
+
const innerWidth = width - (borderWidth * 2);
|
|
271
|
+
const innerHeight = height - (borderWidth * 2);
|
|
272
|
+
|
|
273
|
+
// Calculate radii for the outer dimensions first
|
|
274
|
+
const outerRadii = calculateCornerRadii(
|
|
275
|
+
options.radius,
|
|
276
|
+
width,
|
|
277
|
+
height,
|
|
278
|
+
options.minRadius || 0,
|
|
279
|
+
options.maxRadius || Infinity
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
// check if all radii are 0 - no need for border masking
|
|
283
|
+
if (outerRadii.every(r => r === 0)) {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Adjust radii for the inner path by subtracting border width
|
|
288
|
+
// This ensures consistent border thickness around corners
|
|
289
|
+
const innerRadii = outerRadii.map(r => Math.max(0, r - borderWidth));
|
|
290
|
+
|
|
291
|
+
// Generate the inner path with adjusted radii
|
|
292
|
+
const innerSmoothPath = generateSquirclePath(
|
|
293
|
+
innerWidth,
|
|
294
|
+
innerHeight,
|
|
295
|
+
innerRadii,
|
|
296
|
+
options.smoothing || 0.8,
|
|
297
|
+
options.preserveSmoothing !== false
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
if (!innerSmoothPath) return null;
|
|
301
|
+
|
|
302
|
+
// Apply offset to position the inner path
|
|
303
|
+
const offsetInnerPath = applyPathOffset(innerSmoothPath, borderWidth, borderWidth);
|
|
304
|
+
|
|
305
|
+
// Combine the paths
|
|
306
|
+
const combinedPath = `${rectPath} ${offsetInnerPath}`;
|
|
307
|
+
|
|
308
|
+
// Return format based on render mode
|
|
309
|
+
if (renderMode === 'mask') {
|
|
310
|
+
return createSvgBorderMaskDataUrl(combinedPath, width, height);
|
|
311
|
+
} else {
|
|
312
|
+
return `path(evenodd, '${combinedPath}')`;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Create SVG mask data URL from path string
|
|
319
|
+
* @param {string} pathString - SVG path string
|
|
320
|
+
* @param {number} width - Mask width
|
|
321
|
+
* @param {number} height - Mask height
|
|
322
|
+
* @returns {string} Data URL for SVG mask
|
|
323
|
+
*/
|
|
324
|
+
export function createSvgMaskDataUrl(pathString, width, height) {
|
|
325
|
+
const svgContent = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
|
|
326
|
+
<defs>
|
|
327
|
+
<mask id="smooth-mask">
|
|
328
|
+
<rect width="100%" height="100%" fill="black"/>
|
|
329
|
+
<path d="${pathString}" fill="white"/>
|
|
330
|
+
</mask>
|
|
331
|
+
</defs>
|
|
332
|
+
<rect width="100%" height="100%" fill="white" mask="url(#smooth-mask)"/>
|
|
333
|
+
</svg>`;
|
|
334
|
+
|
|
335
|
+
return `url('data:image/svg+xml;utf8,${encodeURIComponent(svgContent)}')`;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Create SVG border mask data URL using compound path approach
|
|
340
|
+
* @param {string} compoundPathString - Compound path string (outer rect + inner hole)
|
|
341
|
+
* @param {number} width - Mask width
|
|
342
|
+
* @param {number} height - Mask height
|
|
343
|
+
* @returns {string} Data URL for SVG border mask
|
|
344
|
+
*/
|
|
345
|
+
export function createSvgBorderMaskDataUrl(compoundPathString, width, height) {
|
|
346
|
+
const svgContent = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
|
|
347
|
+
<defs>
|
|
348
|
+
<mask id="border-mask">
|
|
349
|
+
<path d="${compoundPathString}" fill="white" fill-rule="evenodd"/>
|
|
350
|
+
</mask>
|
|
351
|
+
</defs>
|
|
352
|
+
<rect width="100%" height="100%" fill="white" mask="url(#border-mask)"/>
|
|
353
|
+
</svg>`;
|
|
354
|
+
|
|
355
|
+
return `url('data:image/svg+xml;utf8,${encodeURIComponent(svgContent)}')`;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Generate smooth corners configuration object
|
|
360
|
+
* @param {Object} options - Configuration options (same as getSmoothCornersPath)
|
|
361
|
+
* @returns {Object} Configuration object with computed values
|
|
362
|
+
*/
|
|
363
|
+
export function generateSmoothCornersConfig(options = {}) {
|
|
364
|
+
const {
|
|
365
|
+
width,
|
|
366
|
+
height,
|
|
367
|
+
radius = 8,
|
|
368
|
+
smoothing = 0.8,
|
|
369
|
+
borderWidth = 0,
|
|
370
|
+
minRadius = 0,
|
|
371
|
+
maxRadius = Infinity,
|
|
372
|
+
preserveSmoothing = true,
|
|
373
|
+
} = options;
|
|
374
|
+
|
|
375
|
+
const adjustedWidth = Math.max(0, width - borderWidth * 2);
|
|
376
|
+
const adjustedHeight = Math.max(0, height - borderWidth * 2);
|
|
377
|
+
|
|
378
|
+
const radii = calculateCornerRadii(radius, adjustedWidth, adjustedHeight, minRadius, maxRadius);
|
|
379
|
+
|
|
380
|
+
// adjust radii for inner path when borderWidth is present
|
|
381
|
+
const adjustedRadii = borderWidth > 0
|
|
382
|
+
? radii.map(r => Math.max(0, r - borderWidth))
|
|
383
|
+
: radii;
|
|
384
|
+
|
|
385
|
+
return {
|
|
386
|
+
width: adjustedWidth,
|
|
387
|
+
height: adjustedHeight,
|
|
388
|
+
radii: adjustedRadii,
|
|
389
|
+
originalRadii: radii,
|
|
390
|
+
smoothing,
|
|
391
|
+
preserveSmoothing,
|
|
392
|
+
borderWidth,
|
|
393
|
+
clipPath: getSmoothCornersPath(options),
|
|
394
|
+
};
|
|
395
|
+
}
|