-
-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
3b95add
commit 5738c21
Showing
11 changed files
with
765 additions
and
16 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -23,3 +23,4 @@ node_modules | |
coverage/ | ||
**/public/assets/js/* | ||
**/public/assets/live2d/models/* | ||
**/public/assets/vrm/models/* |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -25,6 +25,7 @@ words: | |
- cubismviewmatrix | ||
- demi | ||
- elevenlabs | ||
- gltf | ||
- hiyori | ||
- iconify | ||
- intlify | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
<script lang="ts" setup> | ||
import { watchEffect } from 'vue' | ||
import TransitionVertical from './TransitionVertical.vue' | ||
const props = defineProps<{ | ||
default?: boolean | ||
label?: string | ||
}>() | ||
const isVisible = defineModel<boolean>({ default: false }) | ||
watchEffect(() => { | ||
if (props.default != null) { | ||
isVisible.value = !!props.default | ||
} | ||
}) | ||
</script> | ||
|
||
<template> | ||
<div flex="~ col" border="~ gray/25 rounded-lg" divide="y dashed gray/25" of-clip shadow-sm> | ||
<button | ||
sticky top-0 z-10 flex items-center justify-between px2 py1 text-sm backdrop-blur-xl | ||
@click="isVisible = !isVisible" | ||
> | ||
<span> | ||
<slot name="label"> | ||
{{ props.label ?? 'Collapsable' }} | ||
</slot> | ||
</span> <span op50>{{ isVisible ? '▲' : '▼' }}</span> | ||
</button> | ||
<TransitionVertical> | ||
<div v-if="isVisible" w-full> | ||
<slot /> | ||
</div> | ||
</TransitionVertical> | ||
</div> | ||
</template> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,204 @@ | ||
<script setup lang="ts"> | ||
import { computed, ref } from 'vue' | ||
const props = withDefaults(defineProps<{ | ||
values: number[] | ||
min?: number | ||
max?: number | ||
step?: number | ||
disabled?: boolean | ||
}>(), { | ||
min: 0, | ||
max: 100, | ||
step: 1, | ||
disabled: false, | ||
}) | ||
const emit = defineEmits<{ | ||
(e: 'update:values', value: number[]): void | ||
(e: 'mousedown', event: MouseEvent): void | ||
}>() | ||
function clamp(value: number, min: number, max: number): number { | ||
return Math.min(Math.max(value, min), max) | ||
} | ||
const sliderRef = ref<HTMLElement | null>(null) | ||
const isDragging = ref(false) | ||
const previousIndex = ref<number>(0) | ||
// Utility functions | ||
const asc = (a: number, b: number) => a - b | ||
function findClosest(values: number[], currentValue: number) { | ||
const { index: closestIndex } = values.reduce((acc: { distance: number, index: number } | null, value: number, index: number) => { | ||
const distance = Math.abs(currentValue - value) | ||
if (acc === null || distance < acc.distance || distance === acc.distance) { | ||
return { distance, index } | ||
} | ||
return acc | ||
}, null) || { index: 0 } | ||
return closestIndex | ||
} | ||
function valueToPercent(value: number, min: number, max: number) { | ||
return ((value - min) * 100) / (max - min) | ||
} | ||
function percentToValue(percent: number, min: number, max: number) { | ||
return (max - min) * percent + min | ||
} | ||
function getDecimalPrecision(num: number) { | ||
if (Math.abs(num) < 1) { | ||
const parts = num.toExponential().split('e-') | ||
const matissaDecimalPart = parts[0].split('.')[1] | ||
return (matissaDecimalPart ? matissaDecimalPart.length : 0) + Number.parseInt(parts[1], 10) | ||
} | ||
const decimalPart = num.toString().split('.')[1] | ||
return decimalPart ? decimalPart.length : 0 | ||
} | ||
function roundValueToStep(value: number, step: number) { | ||
const nearest = Math.round(value / step) * step | ||
return Number(nearest.toFixed(getDecimalPrecision(step))) | ||
} | ||
// Computed values | ||
const sortedValues = computed(() => { | ||
return [...props.values] | ||
.sort(asc) | ||
.map(value => clamp(value, props.min, props.max)) | ||
}) | ||
const sliderStyle = computed(() => { | ||
const sliderOffset = valueToPercent(sortedValues.value[0], props.min, props.max) | ||
const sliderLeap = valueToPercent(sortedValues.value[sortedValues.value.length - 1], props.min, props.max) - sliderOffset | ||
return { | ||
left: `${sliderOffset}%`, | ||
width: `${sliderLeap}%`, | ||
backgroundSize: `${sliderLeap}% 100%`, | ||
} | ||
}) | ||
// Event handlers | ||
function getNewValue(event: MouseEvent, move = false) { | ||
if (!sliderRef.value) | ||
return { newValue: sortedValues.value, activeIndex: 0 } | ||
const { width, left } = sliderRef.value.getBoundingClientRect() | ||
const percent = (event.clientX - left) / width | ||
let currentValue = percentToValue(percent, props.min, props.max) | ||
currentValue = roundValueToStep(currentValue, props.step) | ||
currentValue = clamp(currentValue, props.min, props.max) | ||
const activeIndex = move ? previousIndex.value : findClosest(sortedValues.value, currentValue) | ||
const newValues = [...sortedValues.value] | ||
newValues[activeIndex] = currentValue | ||
const sortedNewValues = [...newValues].sort(asc) | ||
const newActiveIndex = sortedNewValues.indexOf(currentValue) | ||
previousIndex.value = newActiveIndex | ||
return { | ||
newValue: sortedNewValues, | ||
activeIndex: newActiveIndex, | ||
} | ||
} | ||
function handleMouseDown(event: MouseEvent) { | ||
if (props.disabled) | ||
return | ||
event.preventDefault() | ||
isDragging.value = true | ||
emit('mousedown', event) | ||
const { newValue } = getNewValue(event) | ||
emit('update:values', newValue) | ||
} | ||
function handleMouseMove(event: MouseEvent) { | ||
if (!isDragging.value || props.disabled) | ||
return | ||
const { newValue } = getNewValue(event, true) | ||
emit('update:values', newValue) | ||
} | ||
function handleMouseUp(_: MouseEvent) { | ||
if (!isDragging.value) | ||
return | ||
isDragging.value = false | ||
} | ||
function handleMouseLeave(event: MouseEvent) { | ||
if (!isDragging.value) | ||
return | ||
handleMouseUp(event) | ||
} | ||
</script> | ||
|
||
<template> | ||
<span | ||
ref="sliderRef" | ||
class="range-slider" | ||
:class="{ disabled }" | ||
@mousedown="handleMouseDown" | ||
@mousemove="handleMouseMove" | ||
@mouseup="handleMouseUp" | ||
@mouseleave="handleMouseLeave" | ||
> | ||
<span | ||
class="slider-track" | ||
:style="sliderStyle" | ||
/> | ||
<span | ||
v-for="(value, index) in sortedValues" | ||
:key="index" | ||
role="slider" | ||
class="slider-thumb" | ||
:style="{ left: `${valueToPercent(value, min, max)}%` }" | ||
:data-index="index" | ||
/> | ||
</span> | ||
</template> | ||
|
||
<style scoped> | ||
.range-slider { | ||
width: 100%; | ||
box-sizing: border-box; | ||
display: inline-block; | ||
cursor: ew-resize; | ||
touch-action: none; | ||
border: 3px solid #4bb9fd; | ||
position: relative; | ||
} | ||
.range-slider.disabled { | ||
cursor: default; | ||
pointer-events: none; | ||
opacity: 0.5; | ||
} | ||
.slider-track { | ||
display: block; | ||
position: relative; | ||
background-color: #4bb9fd; | ||
background-image: linear-gradient(90deg, var(--primary-light), var(--primary-light)); | ||
background-repeat: no-repeat; | ||
height: 14px; | ||
} | ||
.slider-thumb { | ||
position: absolute; | ||
width: 10px; | ||
height: 10px; | ||
background: white; | ||
top: 50%; | ||
transform: translate(-50%, -50%); | ||
} | ||
</style> |
Oops, something went wrong.