mirror of
https://github.com/requarks/wiki.git
synced 2026-10-12 02:21:48 +00:00
feat: resize images from file manager
This commit is contained in:
@@ -423,6 +423,139 @@ async function routes(app: FastifyInstance) {
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* RESIZE IMAGE
|
||||
*/
|
||||
app.post<{
|
||||
Params: { siteId: string; assetId: string }
|
||||
Body: {
|
||||
width: number
|
||||
height: number
|
||||
quality?: number
|
||||
mode: 'replace' | 'saveAs'
|
||||
fileName?: string
|
||||
}
|
||||
}>(
|
||||
'/sites/:siteId/assets/:assetId/resize',
|
||||
{
|
||||
/*
|
||||
No route-level `permissions`: that hook reads the group-wide list, and asset permissions come
|
||||
from a group's RULES, which address the folder the file is in. Checked below.
|
||||
*/
|
||||
schema: {
|
||||
summary: 'Resize an image',
|
||||
description: `Resizes a PNG, JPEG, WebP or GIF image to exactly \`width\` × \`height\` — sending a pair that does not keep the original ratio stretches it. Neither may exceed the original: an image can be made smaller, not larger. An animated GIF or WebP keeps its frames when saved as GIF or WebP, and is reduced to its first frame otherwise. SVG is refused, having no pixel size to change.\n\n\`replace\` writes the result over the file, which keeps its ID and its name, so every page showing it shows the new one. \`saveAs\` stores it as a new file named \`fileName\` in the same folder and locale, settled by the site's upload conflict behavior like any other upload, and encoded as whatever format that name's extension says.\n\nNeeds \`write:assets\` where the result is written, and \`read:assets\` on the original. Needs the Sharp extension, and answers 503 without it.`,
|
||||
tags: ['Assets'],
|
||||
params: assetIdParam,
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['width', 'height', 'mode'],
|
||||
properties: {
|
||||
width: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
maximum: 65535
|
||||
},
|
||||
height: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
maximum: 65535
|
||||
},
|
||||
quality: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
maximum: 100,
|
||||
default: 80,
|
||||
description: 'Only applies to JPEG and WebP. PNG and GIF are lossless and ignore it.'
|
||||
},
|
||||
mode: {
|
||||
type: 'string',
|
||||
enum: ['replace', 'saveAs']
|
||||
},
|
||||
fileName: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: 255,
|
||||
description:
|
||||
'Required for `saveAs`, including the extension. Sanitized, so the stored name may differ from the one sent.'
|
||||
}
|
||||
}
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'Image resized successfully',
|
||||
type: 'object',
|
||||
properties: {
|
||||
ok: {
|
||||
type: 'boolean'
|
||||
},
|
||||
message: {
|
||||
type: 'string'
|
||||
},
|
||||
asset: { $ref: 'Asset#' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
// -> Whatever is written records who wrote it, and an API key is not a who
|
||||
const authorId = req.session?.authenticated ? req.session.user?.id : null
|
||||
if (!authorId) {
|
||||
return reply.unauthorized('Resizing an image requires a logged in user.')
|
||||
}
|
||||
const isSaveAs = req.body.mode === 'saveAs'
|
||||
if (isSaveAs && !req.body.fileName) {
|
||||
return reply.badRequest('A file name is required to save the resized image as a new file.')
|
||||
}
|
||||
const source = await WIKI.models.assets.getAsset(req.params.siteId, req.params.assetId)
|
||||
if (!source || !mayOnAsset(req, 'read:assets', source)) {
|
||||
return reply.notFound('This asset does not exist.')
|
||||
}
|
||||
// -> Where the result lands: the file itself, or a new name beside it
|
||||
const destination = {
|
||||
folderPath: source.folderPath,
|
||||
fileName: isSaveAs ? req.body.fileName! : source.fileName,
|
||||
locale: source.locale
|
||||
}
|
||||
if (!mayOnAsset(req, 'write:assets', destination)) {
|
||||
return reply.forbidden('You are not allowed to write a file here.')
|
||||
}
|
||||
const asset = await WIKI.models.assets.resizeImage({
|
||||
siteId: req.params.siteId,
|
||||
id: req.params.assetId,
|
||||
width: req.body.width,
|
||||
height: req.body.height,
|
||||
quality: req.body.quality ?? 80,
|
||||
saveAs: isSaveAs ? req.body.fileName : null,
|
||||
authorId
|
||||
})
|
||||
if (!asset) {
|
||||
return reply.notFound('This asset does not exist.')
|
||||
}
|
||||
|
||||
await audit(req, 'asset', 'resizeAsset', {
|
||||
assetId: asset.id,
|
||||
sourceAssetId: source.id,
|
||||
siteId: req.params.siteId,
|
||||
locale: source.locale,
|
||||
folderPath: source.folderPath,
|
||||
fileName: asset.fileName,
|
||||
mode: req.body.mode,
|
||||
width: req.body.width,
|
||||
height: req.body.height,
|
||||
previousWidth: source.width,
|
||||
previousHeight: source.height
|
||||
})
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
message: 'Image resized successfully.',
|
||||
asset
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* DELETE ASSET
|
||||
*/
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* normalizing it when the Sharp extension is available.
|
||||
*/
|
||||
|
||||
import { CustomError } from './common.ts'
|
||||
|
||||
/** The image formats an upload may use. */
|
||||
export const imageMimeTypes = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const
|
||||
|
||||
@@ -241,3 +243,119 @@ export async function readImageDimensions(data: Buffer): Promise<ImageDimensions
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The raster formats an image can be resized INTO, keyed by the file extension that names each one.
|
||||
*
|
||||
* The extension decides rather than the source: a file is served as the type its name says, so a
|
||||
* `photo.png` saved as `photo.jpg` has to actually be a JPEG afterwards.
|
||||
*/
|
||||
export const resizableFormats = {
|
||||
png: 'png',
|
||||
jpg: 'jpeg',
|
||||
jpeg: 'jpeg',
|
||||
webp: 'webp',
|
||||
gif: 'gif'
|
||||
} as const
|
||||
|
||||
export type ResizableFormat = (typeof resizableFormats)[keyof typeof resizableFormats]
|
||||
|
||||
/** The formats that can hold an animation, and so keep every frame of one. */
|
||||
const animatedFormats = new Set<ResizableFormat>(['gif', 'webp'])
|
||||
|
||||
/** The formats that honour a quality setting. PNG and GIF are lossless, and ignore one. */
|
||||
const lossyFormats = new Set<ResizableFormat>(['jpeg', 'webp'])
|
||||
|
||||
/** What an image is resized to, and how it is encoded afterwards. */
|
||||
export type ImageResize = {
|
||||
width: number
|
||||
height: number
|
||||
format: ResizableFormat
|
||||
/** 1 to 100. Only consulted for a lossy format. */
|
||||
quality: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize an image to exactly the given size, using the Sharp extension.
|
||||
*
|
||||
* Unlike the helpers above, this is something a person asked for rather than something done on their
|
||||
* behalf, so there is no quiet fallback to the original bytes: every way it can fail is thrown as an
|
||||
* error the caller can show them. Sharp being absent is the one they can do something about, and is
|
||||
* reported apart from an image Sharp could not read.
|
||||
*
|
||||
* Both dimensions are applied as given, so a pair that does not keep the ratio stretches the image —
|
||||
* keeping it is the dialog's job, where the lock is. Neither may exceed the original, measured as a
|
||||
* viewer sees it (after EXIF orientation, which is applied here too): enlarging costs bytes to look
|
||||
* worse.
|
||||
*
|
||||
* An animated GIF or WebP keeps every frame, unless it is saved as a format that cannot animate.
|
||||
*
|
||||
* @throws `imageResizeSharpMissing` (503), `imageResizeUnreadable` (422) or `imageResizeEnlarge` (400)
|
||||
*/
|
||||
export async function resizeImage(
|
||||
data: Buffer,
|
||||
{ width, height, format, quality }: ImageResize
|
||||
): Promise<Buffer> {
|
||||
const definition = WIKI.models.extensions.getDefinition('sharp')
|
||||
if (!definition || !(await WIKI.models.extensions.isInstalled(definition))) {
|
||||
throw new CustomError(
|
||||
'imageResizeSharpMissing',
|
||||
'Resizing an image needs the Sharp extension, which is not installed on this server. An administrator can install it under Administration → Extensions.',
|
||||
503
|
||||
)
|
||||
}
|
||||
const specifier = 'sharp'
|
||||
let sharp: any
|
||||
try {
|
||||
;({ default: sharp } = await import(specifier))
|
||||
} catch (err: any) {
|
||||
WIKI.models.extensions.noteLoadFailure(specifier)
|
||||
WIKI.logger.warn(`Could not load Sharp to resize an image: ${err.message}`)
|
||||
throw new CustomError(
|
||||
'imageResizeSharpMissing',
|
||||
'Resizing an image needs the Sharp extension, which could not be loaded on this server. An administrator can reinstall it under Administration → Extensions, then restart the server.',
|
||||
503
|
||||
)
|
||||
}
|
||||
|
||||
// -> Every frame where the result can hold them, so that resizing an animation does not quietly
|
||||
// turn it into a still. PNG and JPEG cannot, and read all frames they would come out as every
|
||||
// frame stacked into one tall picture, so they take the first. A still reads the same either way.
|
||||
const input = { animated: animatedFormats.has(format), autoOrient: true }
|
||||
let original: ImageDimensions | null
|
||||
try {
|
||||
const { width: w, height: h, pageHeight, orientation } = await sharp(data, input).metadata()
|
||||
// -> An animation is stacked frame over frame, so its height as a whole is every frame's together
|
||||
const frameHeight = pageHeight || h
|
||||
original =
|
||||
w && frameHeight
|
||||
? orientation && orientation >= 5
|
||||
? { width: frameHeight, height: w }
|
||||
: { width: w, height: frameHeight }
|
||||
: null
|
||||
} catch (err: any) {
|
||||
WIKI.logger.debug(`Could not read an image to resize: ${err.message}`)
|
||||
original = null
|
||||
}
|
||||
if (!original) {
|
||||
throw new CustomError('imageResizeUnreadable', 'This image could not be read.', 422)
|
||||
}
|
||||
if (width > original.width || height > original.height) {
|
||||
throw new CustomError(
|
||||
'imageResizeEnlarge',
|
||||
`An image can only be made smaller: this one is ${original.width} × ${original.height}.`
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
let pipeline = sharp(data, input).resize(width, height, { fit: 'fill' })
|
||||
if (format === 'jpeg') {
|
||||
// -> JPEG has no alpha channel, and dropping one leaves whatever was transparent black
|
||||
pipeline = pipeline.flatten({ background: '#ffffff' })
|
||||
}
|
||||
return await pipeline.toFormat(format, lossyFormats.has(format) ? { quality } : {}).toBuffer()
|
||||
} catch (err: any) {
|
||||
WIKI.logger.warn(`Could not resize an image: ${err.message}`)
|
||||
throw new CustomError('imageResizeUnreadable', 'This image could not be resized.', 422)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +195,7 @@
|
||||
"admin.audit.actions.requestPasswordReset": "Requested a password reset",
|
||||
"admin.audit.actions.resetPassword": "Reset a password from an emailed link",
|
||||
"admin.audit.actions.resetUserPassword": "Set a user's password",
|
||||
"admin.audit.actions.resizeAsset": "Resized an image",
|
||||
"admin.audit.actions.restorePage": "Restored a deleted page",
|
||||
"admin.audit.actions.retryJob": "Retried a job",
|
||||
"admin.audit.actions.revokeApiKey": "Revoked an API key",
|
||||
@@ -2678,6 +2679,28 @@
|
||||
"fileman.renameAssetInvalid": "Asset name is invalid.",
|
||||
"fileman.renameFolderInvalidData": "One or more fields are invalid.",
|
||||
"fileman.renameFolderSuccess": "Folder renamed successfully.",
|
||||
"fileman.resizeImage": "Resize Image",
|
||||
"fileman.resizeImageAction": "Resize",
|
||||
"fileman.resizeImageDimensionInvalid": "Must be a whole number from 1 to {max}.",
|
||||
"fileman.resizeImageFailed": "Failed to resize image.",
|
||||
"fileman.resizeImageFileName": "New File Name",
|
||||
"fileman.resizeImageFileNameHint": "Including the extension, which decides the format it is saved as.",
|
||||
"fileman.resizeImageFileNameInvalid": "Must end in .png, .jpg, .jpeg, .webp or .gif.",
|
||||
"fileman.resizeImageFileNameMissing": "Enter a file name.",
|
||||
"fileman.resizeImageHeight": "Height",
|
||||
"fileman.resizeImageInvalidData": "One or more fields are invalid.",
|
||||
"fileman.resizeImageLockRatio": "Lock aspect ratio",
|
||||
"fileman.resizeImageMeasureFailed": "Could not read the size of this image.",
|
||||
"fileman.resizeImageOriginal": "Original size: {width} × {height} px. An image can only be made smaller.",
|
||||
"fileman.resizeImagePercent": "Scale",
|
||||
"fileman.resizeImagePercentInvalid": "Must be more than 0 and at most 100.",
|
||||
"fileman.resizeImageQuality": "Quality",
|
||||
"fileman.resizeImageQualityLossless": "{format} is lossless, so quality does not apply.",
|
||||
"fileman.resizeImageReplace": "Replace existing file",
|
||||
"fileman.resizeImageSaveAs": "Save as a new file",
|
||||
"fileman.resizeImageSuccess": "Image resized successfully.",
|
||||
"fileman.resizeImageUnlockRatio": "Unlock aspect ratio",
|
||||
"fileman.resizeImageWidth": "Width",
|
||||
"fileman.restoreFailed": "Failed to restore the page.",
|
||||
"fileman.restorePageTo": "Restore Page To...",
|
||||
"fileman.restorePathTaken": "Another page now exists at /{path}. Choose where to restore this page instead.",
|
||||
@@ -2697,6 +2720,7 @@
|
||||
"fileman.viewOptions": "View Options",
|
||||
"fileman.visualPageType": "Visual Page",
|
||||
"fileman.wavFileType": "WAV Audio File",
|
||||
"fileman.webpFileType": "WebP Image",
|
||||
"fileman.wmaFileType": "WMA Audio File",
|
||||
"fileman.wmvFileType": "WMV Video File",
|
||||
"fileman.woff2FileType": "Web Open Font File",
|
||||
|
||||
+102
-1
@@ -9,7 +9,12 @@ import {
|
||||
encodeTreePath,
|
||||
normalizeFolderPath
|
||||
} from '../helpers/common.ts'
|
||||
import { makeImageThumbnail, readImageDimensions } from '../helpers/images.ts'
|
||||
import {
|
||||
makeImageThumbnail,
|
||||
readImageDimensions,
|
||||
resizableFormats,
|
||||
resizeImage
|
||||
} from '../helpers/images.ts'
|
||||
import type { ImageDimensions } from '../helpers/images.ts'
|
||||
import type { Readable } from 'node:stream'
|
||||
import type { DeletedEntry } from './tree.ts'
|
||||
@@ -613,6 +618,102 @@ class Assets {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize an image, either over itself or into a new file beside it.
|
||||
*
|
||||
* Over itself is `replace`, the same as an upload landing on the name under the `overwrite`
|
||||
* behavior: the ID, the name and the place stay, so every page showing the image now shows the
|
||||
* smaller one. A new file is an upload into the same folder and locale, and is settled by the site's
|
||||
* conflict behavior exactly as a file dropped there would be — so naming it after the original
|
||||
* replaces it too where the site overwrites, and is refused where it rejects.
|
||||
*
|
||||
* What the result is encoded as comes from the name it is stored under, which is how a PNG saved as
|
||||
* `.webp` becomes one. SVG is refused outright: it is markup, and has no pixels to resize.
|
||||
*
|
||||
* @param saveAs What to call the new file. Absent to resize the image in place.
|
||||
* @returns The resized asset — for a new file, the one the upload produced — or null if there is no
|
||||
* such asset on this site
|
||||
* @throws `imageResizeUnsupported` for a file that is not a raster image, or a name that is not one
|
||||
* of the formats it can be written as, plus whatever `resizeImage` and `upload` throw
|
||||
*/
|
||||
async resizeImage({
|
||||
siteId,
|
||||
id,
|
||||
width,
|
||||
height,
|
||||
quality,
|
||||
saveAs,
|
||||
authorId
|
||||
}: {
|
||||
siteId: string
|
||||
id: string
|
||||
width: number
|
||||
height: number
|
||||
quality: number
|
||||
saveAs?: string | null
|
||||
authorId: string
|
||||
}): Promise<Asset | null> {
|
||||
const source = await this.getAsset(siteId, id)
|
||||
if (!source) {
|
||||
return null
|
||||
}
|
||||
if (!Object.hasOwn(resizableFormats, source.fileExt)) {
|
||||
throw new CustomError(
|
||||
'imageResizeUnsupported',
|
||||
source.fileExt === 'svg'
|
||||
? 'An SVG is a vector image, so it has no pixel size to change.'
|
||||
: 'Only PNG, JPEG, WebP and GIF images can be resized.'
|
||||
)
|
||||
}
|
||||
const targetName = saveAs ? sanitizeFileName(saveAs) : source.fileName
|
||||
if (!targetName) {
|
||||
throw new CustomError('assetInvalidFileName', 'This file name cannot be used.')
|
||||
}
|
||||
const targetExt = extensionOf(targetName)
|
||||
const format = resizableFormats[targetExt as keyof typeof resizableFormats]
|
||||
if (!format) {
|
||||
throw new CustomError(
|
||||
'imageResizeUnsupported',
|
||||
'A resized image has to be saved as .png, .jpg, .jpeg, .webp or .gif.'
|
||||
)
|
||||
}
|
||||
|
||||
const content = await this.getContent(id)
|
||||
if (!content) {
|
||||
throw new CustomError('assetNoContent', 'This file has no content to resize.', 404)
|
||||
}
|
||||
const data = await resizeImage(content.data, { width, height, format, quality })
|
||||
|
||||
if (saveAs) {
|
||||
return this.upload({
|
||||
siteId,
|
||||
locale: source.locale,
|
||||
folderPath: source.folderPath,
|
||||
fileName: targetName,
|
||||
data,
|
||||
authorId
|
||||
})
|
||||
}
|
||||
|
||||
const mimeType = mime.getType(targetName) ?? content.mimeType
|
||||
return this.replace({
|
||||
id,
|
||||
siteId,
|
||||
locale: source.locale,
|
||||
folderPath: source.folderPath,
|
||||
fileName: source.fileName,
|
||||
title: source.title,
|
||||
fileExt: targetExt,
|
||||
kind: 'image',
|
||||
mimeType,
|
||||
data,
|
||||
preview: await makeImageThumbnail(data, THUMBNAIL_SIZE.width, THUMBNAIL_SIZE.height),
|
||||
// -> Known rather than measured: the resize wrote exactly this, the right way up
|
||||
dimensions: { width, height },
|
||||
authorId
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* An asset's metadata, without its bytes. Null if there is no such asset on this site.
|
||||
*/
|
||||
|
||||
@@ -48,7 +48,7 @@ export const AUDIT_ACTIONS = {
|
||||
'setFolderColor',
|
||||
'deleteFolder'
|
||||
],
|
||||
asset: ['uploadAsset', 'updateAsset', 'deleteAsset'],
|
||||
asset: ['uploadAsset', 'updateAsset', 'resizeAsset', 'deleteAsset'],
|
||||
comment: ['createComment', 'updateComment', 'deleteComment'],
|
||||
auth: [
|
||||
'login',
|
||||
|
||||
@@ -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>
|
||||
@@ -566,25 +566,34 @@
|
||||
</w-item-section>
|
||||
<w-item-section>{{ t(`common.actions.view`) }}</w-item-section>
|
||||
</w-item>
|
||||
<!-- -> Behind the experimental flag: neither has a handler behind it yet,
|
||||
so on an ordinary instance they are two rows that do nothing -->
|
||||
<template
|
||||
<!-- -> Behind the experimental flag: it has no handler behind it yet, so on
|
||||
an ordinary instance it is a row that does nothing -->
|
||||
<w-item
|
||||
clickable
|
||||
v-if="
|
||||
flagsStore.experimental && item.type === `asset` && item.imageEdit
|
||||
">
|
||||
<w-item clickable>
|
||||
<w-item-section side>
|
||||
<w-icon name="la:edit" color="orange" />
|
||||
</w-item-section>
|
||||
<w-item-section>Edit Image...</w-item-section>
|
||||
</w-item>
|
||||
<w-item clickable>
|
||||
<w-item-section side>
|
||||
<w-icon name="la:crop" color="orange" />
|
||||
</w-item-section>
|
||||
<w-item-section>Resize Image...</w-item-section>
|
||||
</w-item>
|
||||
</template>
|
||||
<w-item-section side>
|
||||
<w-icon name="la:edit" color="orange" />
|
||||
</w-item-section>
|
||||
<w-item-section>Edit Image...</w-item-section>
|
||||
</w-item>
|
||||
<!-- -> `imageEdit` is on the raster formats only, so never an SVG. And
|
||||
`can` answers whether write:assets is held ANYWHERE: the rule over
|
||||
this file's folder is the server's to check, per path. -->
|
||||
<w-item
|
||||
clickable
|
||||
v-if="
|
||||
item.type === `asset` &&
|
||||
item.imageEdit &&
|
||||
userStore.can(`write:assets`)
|
||||
"
|
||||
@click="resizeImage(item)">
|
||||
<w-item-section side>
|
||||
<w-icon name="la:crop" color="orange" />
|
||||
</w-item-section>
|
||||
<w-item-section>{{ t(`fileman.resizeImage`) }}...</w-item-section>
|
||||
</w-item>
|
||||
<w-item
|
||||
clickable
|
||||
v-if="item.type !== `folder`"
|
||||
@@ -1860,6 +1869,24 @@ function renameMoveAsset(item) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize an image, over itself or into a new file beside it. Either way the folder is listed again:
|
||||
* a new file has to appear in it, and a replaced one has a new size and thumbnail to show.
|
||||
*/
|
||||
function resizeImage(item) {
|
||||
dialog({
|
||||
component: defineAsyncComponent(() => import('@/components/AssetResizeDialog.vue')),
|
||||
componentProps: {
|
||||
assetId: item.id,
|
||||
fileName: item.fileName,
|
||||
width: item.width ?? null,
|
||||
height: item.height ?? null
|
||||
}
|
||||
}).onOk(async () => {
|
||||
await loadTree({ parentId: state.currentFolderId })
|
||||
})
|
||||
}
|
||||
|
||||
function delAsset(assetId, assetName) {
|
||||
dialog({
|
||||
component: defineAsyncComponent(() => import('@/components/AssetDeleteDialog.vue')),
|
||||
|
||||
@@ -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>
|
||||
@@ -53,6 +53,7 @@ import WScrollArea from './WScrollArea.vue'
|
||||
import WSelect from './WSelect.vue'
|
||||
import WSeparator from './WSeparator.vue'
|
||||
import WSignal from './WSignal.vue'
|
||||
import WSlider from './WSlider.vue'
|
||||
import WSpace from './WSpace.vue'
|
||||
import WSpinner from './WSpinner.vue'
|
||||
import WTab from './WTab.vue'
|
||||
@@ -113,6 +114,7 @@ export const sharedComponents = {
|
||||
WSelect,
|
||||
WSeparator,
|
||||
WSignal,
|
||||
WSlider,
|
||||
WSpace,
|
||||
WSpinner,
|
||||
WTab,
|
||||
|
||||
@@ -56,7 +56,8 @@ export default {
|
||||
icon: 'img:/_assets/icons/fluent-folder.svg'
|
||||
},
|
||||
gif: {
|
||||
icon: 'img:/_assets/icons/color-gif.svg'
|
||||
icon: 'img:/_assets/icons/color-gif.svg',
|
||||
imageEdit: true
|
||||
},
|
||||
gz: {
|
||||
icon: 'img:/_assets/icons/color-archive.svg'
|
||||
@@ -156,6 +157,10 @@ export default {
|
||||
wav: {
|
||||
icon: 'img:/_assets/icons/color-wav.svg'
|
||||
},
|
||||
webp: {
|
||||
icon: 'img:/_assets/icons/color-image-file.svg',
|
||||
imageEdit: true
|
||||
},
|
||||
wma: {
|
||||
icon: 'img:/_assets/icons/color-audio-file.svg'
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user