mirror of https://github.com/requarks/wiki
parent
41b1e0e9b5
commit
2cf5b2569e
@ -0,0 +1,466 @@
|
|||||||
|
<template>
|
||||||
|
<w-dialog v-model="dialogVisible" @hide="onDialogHide">
|
||||||
|
<w-card class="relative" style="min-width: 600px">
|
||||||
|
<w-card-section class="card-header">
|
||||||
|
<w-icon name="img:/_assets/icons/ultraviolet-full-image.svg" size="sm" class="mr-2" />
|
||||||
|
<span>{{ t(`fileman.resizeImage`) }}</span>
|
||||||
|
</w-card-section>
|
||||||
|
<w-form ref="resizeForm" class="py-2" @submit="resize">
|
||||||
|
<!-- SIZE -->
|
||||||
|
<w-item>
|
||||||
|
<!-- -> Top-aligned, since the caption below would pull a centred icon off the fields, and
|
||||||
|
then lowered to centre on them: an outlined field starts below its row's top, the
|
||||||
|
space above it held for the floating label. As in LocaleAliasesDialog. -->
|
||||||
|
<blueprint-icon icon="width" class="self-start mt-1.5" />
|
||||||
|
<w-item-section>
|
||||||
|
<div class="flex flex-nowrap items-start gap-2">
|
||||||
|
<w-input
|
||||||
|
ref="iptWidth"
|
||||||
|
class="flex-1"
|
||||||
|
:model-value="state.width"
|
||||||
|
type="number"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
suffix="px"
|
||||||
|
:label="t(`fileman.resizeImageWidth`)"
|
||||||
|
:rules="widthValidation"
|
||||||
|
hide-bottom-space
|
||||||
|
lazy-rules="ondemand"
|
||||||
|
:disable="!isMeasured"
|
||||||
|
@update:model-value="setWidth"
|
||||||
|
@keyup:enter="resize" />
|
||||||
|
<w-btn
|
||||||
|
class="mt-1"
|
||||||
|
flat
|
||||||
|
round
|
||||||
|
dense
|
||||||
|
:icon="state.isRatioLocked ? `la:lock` : `la:lock-open`"
|
||||||
|
:color="state.isRatioLocked ? `primary` : `grey`"
|
||||||
|
:aria-pressed="String(state.isRatioLocked)"
|
||||||
|
:aria-label="t(`fileman.resizeImageLockRatio`)"
|
||||||
|
:disable="!isMeasured"
|
||||||
|
@click="toggleRatioLock">
|
||||||
|
<w-tooltip>{{
|
||||||
|
state.isRatioLocked
|
||||||
|
? t(`fileman.resizeImageUnlockRatio`)
|
||||||
|
: t(`fileman.resizeImageLockRatio`)
|
||||||
|
}}</w-tooltip>
|
||||||
|
</w-btn>
|
||||||
|
<w-input
|
||||||
|
class="flex-1"
|
||||||
|
:model-value="state.height"
|
||||||
|
type="number"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
suffix="px"
|
||||||
|
:label="t(`fileman.resizeImageHeight`)"
|
||||||
|
:rules="heightValidation"
|
||||||
|
hide-bottom-space
|
||||||
|
lazy-rules="ondemand"
|
||||||
|
:disable="!isMeasured"
|
||||||
|
@update:model-value="setHeight"
|
||||||
|
@keyup:enter="resize" />
|
||||||
|
<w-input
|
||||||
|
class="w-28 shrink-0"
|
||||||
|
:model-value="state.percent"
|
||||||
|
type="number"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
suffix="%"
|
||||||
|
:label="t(`fileman.resizeImagePercent`)"
|
||||||
|
:rules="percentValidation"
|
||||||
|
hide-bottom-space
|
||||||
|
lazy-rules="ondemand"
|
||||||
|
:disable="!isMeasured"
|
||||||
|
@update:model-value="setPercent"
|
||||||
|
@keyup:enter="resize" />
|
||||||
|
</div>
|
||||||
|
<div class="text-caption text-grey mt-1" v-if="isMeasured">
|
||||||
|
{{
|
||||||
|
t('fileman.resizeImageOriginal', {
|
||||||
|
width: state.originalWidth,
|
||||||
|
height: state.originalHeight
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</w-item-section>
|
||||||
|
</w-item>
|
||||||
|
<!-- QUALITY -->
|
||||||
|
<w-item>
|
||||||
|
<blueprint-icon icon="tune" top />
|
||||||
|
<w-item-section>
|
||||||
|
<div class="text-caption text-black/60 dark:text-white/70 mb-1">
|
||||||
|
{{ t('fileman.resizeImageQuality') }}
|
||||||
|
</div>
|
||||||
|
<div class="px-2">
|
||||||
|
<w-slider
|
||||||
|
v-model="state.quality"
|
||||||
|
:min="1"
|
||||||
|
:max="100"
|
||||||
|
:step="10"
|
||||||
|
label
|
||||||
|
markers
|
||||||
|
:disable="!isLossy"
|
||||||
|
:aria-label="t(`fileman.resizeImageQuality`)" />
|
||||||
|
</div>
|
||||||
|
<div class="text-caption text-grey" v-if="!isLossy">
|
||||||
|
{{ t('fileman.resizeImageQualityLossless', { format: targetExt.toUpperCase() }) }}
|
||||||
|
</div>
|
||||||
|
</w-item-section>
|
||||||
|
</w-item>
|
||||||
|
<!-- OUTPUT -->
|
||||||
|
<w-item>
|
||||||
|
<blueprint-icon icon="file-submodule" top />
|
||||||
|
<w-item-section>
|
||||||
|
<div role="radiogroup" class="flex flex-col gap-2">
|
||||||
|
<w-radio
|
||||||
|
v-model="state.mode"
|
||||||
|
val="replace"
|
||||||
|
:label="t(`fileman.resizeImageReplace`)" />
|
||||||
|
<w-radio v-model="state.mode" val="saveAs" :label="t(`fileman.resizeImageSaveAs`)" />
|
||||||
|
</div>
|
||||||
|
<w-input
|
||||||
|
ref="iptFileName"
|
||||||
|
class="mt-3"
|
||||||
|
v-model="state.fileName"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
:label="t(`fileman.resizeImageFileName`)"
|
||||||
|
:hint="t(`fileman.resizeImageFileNameHint`)"
|
||||||
|
:rules="fileNameValidation"
|
||||||
|
lazy-rules="ondemand"
|
||||||
|
:disable="state.mode !== `saveAs`"
|
||||||
|
@keyup:enter="resize" />
|
||||||
|
</w-item-section>
|
||||||
|
</w-item>
|
||||||
|
</w-form>
|
||||||
|
<w-card-actions class="card-actions">
|
||||||
|
<w-space />
|
||||||
|
<w-btn
|
||||||
|
class="acrylic-btn"
|
||||||
|
flat
|
||||||
|
:label="t(`common.actions.cancel`)"
|
||||||
|
color="grey"
|
||||||
|
padding="xs md"
|
||||||
|
@click="onDialogCancel" />
|
||||||
|
<w-btn
|
||||||
|
unelevated
|
||||||
|
:label="t(`fileman.resizeImageAction`)"
|
||||||
|
color="primary"
|
||||||
|
padding="xs md"
|
||||||
|
:loading="state.isSubmitting"
|
||||||
|
:disable="!isMeasured"
|
||||||
|
@click="resize" />
|
||||||
|
</w-card-actions>
|
||||||
|
<w-inner-loading :showing="state.isMeasuring" size="38px" spinner-class="text-accent" />
|
||||||
|
</w-card>
|
||||||
|
</w-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
|
||||||
|
import { notify } from '@/composables/notify'
|
||||||
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import { useSiteStore } from '@/stores/site'
|
||||||
|
import { apiErrorMessage } from '@/helpers/apiError'
|
||||||
|
|
||||||
|
// PROPS
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
assetId: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
fileName: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
/** The size recorded when the file arrived. Absent when it could not be read then. */
|
||||||
|
width: {
|
||||||
|
type: Number,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
type: Number,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// EMITS
|
||||||
|
|
||||||
|
defineEmits([...dialogComponentEmits])
|
||||||
|
|
||||||
|
// DIALOG
|
||||||
|
|
||||||
|
const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent({
|
||||||
|
autofocus: () => iptWidth.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// STORES
|
||||||
|
|
||||||
|
const siteStore = useSiteStore()
|
||||||
|
|
||||||
|
// I18N
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// DATA
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a resized image can be saved as, by extension. The same list the server holds, since the
|
||||||
|
* extension of the name it is stored under is what decides the format.
|
||||||
|
*/
|
||||||
|
const RESIZABLE_EXTS = ['png', 'jpg', 'jpeg', 'webp', 'gif']
|
||||||
|
|
||||||
|
/** PNG and GIF are lossless: the server ignores a quality for them, so the slider says so. */
|
||||||
|
const LOSSY_EXTS = new Set(['jpg', 'jpeg', 'webp'])
|
||||||
|
|
||||||
|
const extIndex = props.fileName.lastIndexOf('.')
|
||||||
|
const sourceStem = extIndex > 0 ? props.fileName.slice(0, extIndex) : props.fileName
|
||||||
|
const sourceExt = extIndex > 0 ? props.fileName.slice(extIndex + 1).toLowerCase() : ''
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
isMeasuring: false,
|
||||||
|
isSubmitting: false,
|
||||||
|
originalWidth: 0,
|
||||||
|
originalHeight: 0,
|
||||||
|
// -> Strings, as the inputs hand them back: a field being typed into is not always a number yet
|
||||||
|
width: '',
|
||||||
|
height: '',
|
||||||
|
percent: '100',
|
||||||
|
isRatioLocked: true,
|
||||||
|
quality: 80,
|
||||||
|
mode: 'replace',
|
||||||
|
fileName: `${sourceStem}-resized.${sourceExt}`
|
||||||
|
})
|
||||||
|
|
||||||
|
// REFS
|
||||||
|
|
||||||
|
const resizeForm = ref(null)
|
||||||
|
const iptWidth = ref(null)
|
||||||
|
const iptFileName = ref(null)
|
||||||
|
|
||||||
|
// COMPUTED
|
||||||
|
|
||||||
|
const isMeasured = computed(() => state.originalWidth > 0 && state.originalHeight > 0)
|
||||||
|
|
||||||
|
/** The extension the result is written under, which is what decides its format. */
|
||||||
|
const targetExt = computed(() => {
|
||||||
|
if (state.mode !== 'saveAs') {
|
||||||
|
return sourceExt
|
||||||
|
}
|
||||||
|
const idx = state.fileName.lastIndexOf('.')
|
||||||
|
return idx > 0 ? state.fileName.slice(idx + 1).toLowerCase() : ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// -> A name that is not one of the formats yet has no say: the field's own rule complains about it
|
||||||
|
const isLossy = computed(
|
||||||
|
() => !RESIZABLE_EXTS.includes(targetExt.value) || LOSSY_EXTS.has(targetExt.value)
|
||||||
|
)
|
||||||
|
|
||||||
|
// VALIDATION RULES
|
||||||
|
|
||||||
|
/** A whole number of pixels, at least one and no more than the original: this only makes smaller. */
|
||||||
|
function dimensionRule(max) {
|
||||||
|
return (val) => {
|
||||||
|
const n = Number(val)
|
||||||
|
return (
|
||||||
|
(Number.isInteger(n) && n >= 1 && n <= max) ||
|
||||||
|
t('fileman.resizeImageDimensionInvalid', { max })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const widthValidation = computed(() => [dimensionRule(state.originalWidth)])
|
||||||
|
const heightValidation = computed(() => [dimensionRule(state.originalHeight)])
|
||||||
|
|
||||||
|
// -> Empty is allowed: an unlocked pair that is not the same scale both ways has no one percentage
|
||||||
|
const percentValidation = [
|
||||||
|
(val) => {
|
||||||
|
if (val === '') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const n = Number(val)
|
||||||
|
return (n > 0 && n <= 100) || t('fileman.resizeImagePercentInvalid')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const fileNameValidation = [
|
||||||
|
(val) =>
|
||||||
|
state.mode !== 'saveAs' || val.trim().length > 0 || t('fileman.resizeImageFileNameMissing'),
|
||||||
|
(val) =>
|
||||||
|
state.mode !== 'saveAs' ||
|
||||||
|
RESIZABLE_EXTS.some((ext) => val.trim().toLowerCase().endsWith(`.${ext}`)) ||
|
||||||
|
t('fileman.resizeImageFileNameInvalid')
|
||||||
|
]
|
||||||
|
|
||||||
|
// WATCHERS
|
||||||
|
|
||||||
|
/*
|
||||||
|
The name field comes alive with the mode, so the caret goes there. Going back to Replace revalidates
|
||||||
|
it instead, which with that mode is a pass: a complaint left on a field nobody can type into would
|
||||||
|
be one there is no way to answer.
|
||||||
|
*/
|
||||||
|
watch(
|
||||||
|
() => state.mode,
|
||||||
|
(mode) => {
|
||||||
|
if (mode === 'saveAs') {
|
||||||
|
setTimeout(() => iptFileName.value?.focus())
|
||||||
|
} else {
|
||||||
|
iptFileName.value?.validate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// METHODS
|
||||||
|
|
||||||
|
/** A scale as the percent field shows it: at most one decimal, and no trailing `.0`. */
|
||||||
|
function formatPercent(ratio) {
|
||||||
|
return String(Math.round(ratio * 1000) / 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bring the percent field in line with the two dimensions.
|
||||||
|
*
|
||||||
|
* Locked, it is simply the width's scale. Unlocked, it is only shown when both sides are at the same
|
||||||
|
* scale -- to the same tenth of a percent, which is as fine as the field is drawn -- since a stretched
|
||||||
|
* pair has no single number that describes it.
|
||||||
|
*/
|
||||||
|
function syncPercent() {
|
||||||
|
const w = Number(state.width)
|
||||||
|
const h = Number(state.height)
|
||||||
|
if (!(w > 0) || !(h > 0)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const scaleW = formatPercent(w / state.originalWidth)
|
||||||
|
state.percent =
|
||||||
|
state.isRatioLocked || scaleW === formatPercent(h / state.originalHeight) ? scaleW : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function setWidth(val) {
|
||||||
|
state.width = val
|
||||||
|
const w = Number(val)
|
||||||
|
if (state.isRatioLocked && w > 0) {
|
||||||
|
state.height = String(Math.max(1, Math.round((w * state.originalHeight) / state.originalWidth)))
|
||||||
|
}
|
||||||
|
syncPercent()
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHeight(val) {
|
||||||
|
state.height = val
|
||||||
|
const h = Number(val)
|
||||||
|
if (state.isRatioLocked && h > 0) {
|
||||||
|
state.width = String(Math.max(1, Math.round((h * state.originalWidth) / state.originalHeight)))
|
||||||
|
}
|
||||||
|
syncPercent()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A percentage scales both sides alike, whether or not the ratio is locked: that is what it means. */
|
||||||
|
function setPercent(val) {
|
||||||
|
state.percent = val
|
||||||
|
const p = Number(val)
|
||||||
|
if (val === '' || !(p > 0)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.width = String(Math.max(1, Math.round((state.originalWidth * p) / 100)))
|
||||||
|
state.height = String(Math.max(1, Math.round((state.originalHeight * p) / 100)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Locking again puts the height back in proportion to the width, which is the side typed first. */
|
||||||
|
function toggleRatioLock() {
|
||||||
|
state.isRatioLocked = !state.isRatioLocked
|
||||||
|
if (state.isRatioLocked) {
|
||||||
|
setWidth(state.width)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the image's size in the browser, for a file whose size was not recorded when it arrived --
|
||||||
|
* which is anything uploaded while the Sharp extension was missing.
|
||||||
|
*
|
||||||
|
* Fetched through the API rather than from `/_files/`, which resolves a path in the site's primary
|
||||||
|
* locale and could hand back a different file of the same name. The browser applies EXIF orientation
|
||||||
|
* when it decodes, so this is the size as displayed, which is what the server measures against.
|
||||||
|
*/
|
||||||
|
async function measureInBrowser() {
|
||||||
|
const blob = await API_CLIENT.get(`sites/${siteStore.id}/assets/${props.assetId}/content`).blob()
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
try {
|
||||||
|
const img = new Image()
|
||||||
|
img.src = url
|
||||||
|
await img.decode()
|
||||||
|
return { width: img.naturalWidth, height: img.naturalHeight }
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resize() {
|
||||||
|
if (state.isSubmitting || !isMeasured.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.isSubmitting = true
|
||||||
|
try {
|
||||||
|
const isFormValid = await resizeForm.value.validate(true)
|
||||||
|
if (!isFormValid) {
|
||||||
|
throw new Error(t('fileman.resizeImageInvalidData'))
|
||||||
|
}
|
||||||
|
const resp = await API_CLIENT.post(`sites/${siteStore.id}/assets/${props.assetId}/resize`, {
|
||||||
|
json: {
|
||||||
|
width: Number(state.width),
|
||||||
|
height: Number(state.height),
|
||||||
|
quality: state.quality,
|
||||||
|
mode: state.mode,
|
||||||
|
...(state.mode === 'saveAs' ? { fileName: state.fileName.trim() } : {})
|
||||||
|
}
|
||||||
|
}).json()
|
||||||
|
// -> The API client does not throw on 400, so a refused resize comes back as a parsed error
|
||||||
|
if (resp?.ok === false) {
|
||||||
|
throw new Error(resp.message || 'An unexpected error occured.')
|
||||||
|
}
|
||||||
|
notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: t('fileman.resizeImageSuccess')
|
||||||
|
})
|
||||||
|
onDialogOK(resp.asset)
|
||||||
|
} catch (err) {
|
||||||
|
// -> Sharp missing on the server is the likeliest failure, and answers 503 saying so
|
||||||
|
notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: t('fileman.resizeImageFailed'),
|
||||||
|
caption: apiErrorMessage(err, 'An unexpected error occured.')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
state.isSubmitting = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MOUNTED
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
let dimensions = props.width && props.height ? { width: props.width, height: props.height } : null
|
||||||
|
if (!dimensions) {
|
||||||
|
state.isMeasuring = true
|
||||||
|
try {
|
||||||
|
dimensions = await measureInBrowser()
|
||||||
|
} catch (err) {
|
||||||
|
notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: t('fileman.resizeImageMeasureFailed'),
|
||||||
|
caption: apiErrorMessage(err, 'An unexpected error occured.')
|
||||||
|
})
|
||||||
|
onDialogCancel()
|
||||||
|
return
|
||||||
|
} finally {
|
||||||
|
state.isMeasuring = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.originalWidth = dimensions.width
|
||||||
|
state.originalHeight = dimensions.height
|
||||||
|
state.width = String(dimensions.width)
|
||||||
|
state.height = String(dimensions.height)
|
||||||
|
state.percent = '100'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@ -0,0 +1,210 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="trackEl"
|
||||||
|
class="w-slider relative h-6 w-full cursor-pointer select-none"
|
||||||
|
:class="[isDisabled ? 'pointer-events-none opacity-60' : '', label ? 'mb-7' : '']"
|
||||||
|
@pointerdown="onPointerDown">
|
||||||
|
<!-- Rail -->
|
||||||
|
<div
|
||||||
|
class="absolute top-1/2 h-1 w-full -translate-y-1/2 rounded-full bg-black/24 dark:bg-white/30" />
|
||||||
|
|
||||||
|
<!-- Filled span, from the lower end to the handle -->
|
||||||
|
<div
|
||||||
|
class="absolute top-1/2 left-0 h-1 -translate-y-1/2 rounded-full"
|
||||||
|
:style="{ width: `${toPercent(model)}%`, backgroundColor: `var(--color-${color})` }" />
|
||||||
|
|
||||||
|
<!-- Stop markers -->
|
||||||
|
<template v-if="markers">
|
||||||
|
<div
|
||||||
|
v-for="value of stops"
|
||||||
|
:key="value"
|
||||||
|
class="absolute top-1/2 h-1 w-1 -translate-x-1/2 -translate-y-1/2 rounded-full bg-black/38 dark:bg-white/50"
|
||||||
|
:style="{ left: `${toPercent(value)}%` }" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="slider"
|
||||||
|
class="w-unstyled absolute top-1/2 h-4 w-4 -translate-x-1/2 -translate-y-1/2 cursor-grab rounded-full shadow-card transition-transform focus-visible:outline-2 focus-visible:outline-offset-2 active:cursor-grabbing"
|
||||||
|
:style="{ left: `${toPercent(model)}%`, backgroundColor: `var(--color-${color})` }"
|
||||||
|
:aria-label="ariaLabel"
|
||||||
|
:aria-valuemin="min"
|
||||||
|
:aria-valuemax="max"
|
||||||
|
:aria-valuenow="model"
|
||||||
|
:aria-valuetext="labelText"
|
||||||
|
:disabled="isDisabled"
|
||||||
|
@keydown="onKeydown">
|
||||||
|
<span
|
||||||
|
v-if="label"
|
||||||
|
class="pointer-events-none absolute top-full left-1/2 mt-1 -translate-x-1/2 rounded px-1.5 py-0.5 text-caption whitespace-nowrap text-white"
|
||||||
|
:style="{ backgroundColor: `var(--color-${color})` }">
|
||||||
|
{{ labelText }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-handle slider over a scale of discrete stops.
|
||||||
|
*
|
||||||
|
* `WRange`'s one-value sibling. The stops are `min`, `max`, and every multiple of `step` strictly
|
||||||
|
* between them -- so `min: 1, max: 100, step: 10` is 1, 10, 20 ... 100, which is what a quality
|
||||||
|
* setting wants and what a plain `min + k * step` could not give (it would stop at 91). With
|
||||||
|
* `min: 0` the two readings are the same thing.
|
||||||
|
*/
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
min: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
max: {
|
||||||
|
type: Number,
|
||||||
|
default: 100
|
||||||
|
},
|
||||||
|
step: {
|
||||||
|
type: Number,
|
||||||
|
default: 1
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
type: String,
|
||||||
|
default: 'primary'
|
||||||
|
},
|
||||||
|
/** Shows a value bubble below the handle. */
|
||||||
|
label: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
/** Overrides the text of the bubble, and of what assistive technology reads out. */
|
||||||
|
labelValue: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
/** Draws a dot at every stop. */
|
||||||
|
markers: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
disable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
ariaLabel: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const trackEl = ref(null)
|
||||||
|
const isDragging = ref(false)
|
||||||
|
|
||||||
|
const isDisabled = computed(() => props.disable)
|
||||||
|
|
||||||
|
const stops = computed(() => {
|
||||||
|
const values = [props.min]
|
||||||
|
const first = (Math.floor(props.min / props.step) + 1) * props.step
|
||||||
|
for (let value = first; value < props.max; value += props.step) {
|
||||||
|
values.push(value)
|
||||||
|
}
|
||||||
|
if (props.max > props.min) {
|
||||||
|
values.push(props.max)
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
})
|
||||||
|
|
||||||
|
/** The stop nearest to a value, which is the only thing the model is ever set to. */
|
||||||
|
function snap(value) {
|
||||||
|
return stops.value.reduce((best, stop) =>
|
||||||
|
Math.abs(stop - value) < Math.abs(best - value) ? stop : best
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = computed(() => snap(props.modelValue ?? props.min))
|
||||||
|
|
||||||
|
const labelText = computed(() => props.labelValue ?? String(model.value))
|
||||||
|
|
||||||
|
function toPercent(value) {
|
||||||
|
const span = props.max - props.min
|
||||||
|
return span === 0
|
||||||
|
? 0
|
||||||
|
: ((Math.min(props.max, Math.max(props.min, value)) - props.min) / span) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(value) {
|
||||||
|
const next = snap(value)
|
||||||
|
if (next !== model.value) {
|
||||||
|
emit('update:modelValue', next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueAt(clientX) {
|
||||||
|
const rect = trackEl.value.getBoundingClientRect()
|
||||||
|
const ratio = rect.width === 0 ? 0 : (clientX - rect.left) / rect.width
|
||||||
|
return props.min + ratio * (props.max - props.min)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Guarded as in `WRange`: capture throws for a synthetic pointer, and the drag works without it. */
|
||||||
|
function capturePointer(ev) {
|
||||||
|
try {
|
||||||
|
ev.currentTarget.setPointerCapture(ev.pointerId)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerDown(ev) {
|
||||||
|
isDragging.value = true
|
||||||
|
update(valueAt(ev.clientX))
|
||||||
|
|
||||||
|
capturePointer(ev)
|
||||||
|
ev.currentTarget.addEventListener('pointermove', onPointerMove)
|
||||||
|
ev.currentTarget.addEventListener('pointerup', onPointerUp, { once: true })
|
||||||
|
ev.currentTarget.addEventListener('pointercancel', onPointerUp, { once: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(ev) {
|
||||||
|
if (isDragging.value) {
|
||||||
|
update(valueAt(ev.clientX))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp(ev) {
|
||||||
|
isDragging.value = false
|
||||||
|
try {
|
||||||
|
ev.currentTarget.releasePointerCapture(ev.pointerId)
|
||||||
|
} catch {}
|
||||||
|
ev.currentTarget.removeEventListener('pointermove', onPointerMove)
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEY_STEPS = {
|
||||||
|
ArrowLeft: -1,
|
||||||
|
ArrowDown: -1,
|
||||||
|
ArrowRight: 1,
|
||||||
|
ArrowUp: 1,
|
||||||
|
PageDown: -1,
|
||||||
|
PageUp: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A key moves one stop, not one unit: the stops are the only values there are. */
|
||||||
|
function onKeydown(ev) {
|
||||||
|
if (ev.key === 'Home' || ev.key === 'End') {
|
||||||
|
ev.preventDefault()
|
||||||
|
update(ev.key === 'Home' ? props.min : props.max)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const delta = KEY_STEPS[ev.key]
|
||||||
|
if (delta === undefined) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ev.preventDefault()
|
||||||
|
const idx = stops.value.indexOf(model.value)
|
||||||
|
const next = stops.value[Math.min(stops.value.length - 1, Math.max(0, idx + delta))]
|
||||||
|
update(next)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
Loading…
Reference in new issue