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