feat: resize images from file manager

pull/8104/head 3.0.0-beta.605
NGPixel 3 days ago
parent 41b1e0e9b5
commit 2cf5b2569e
No known key found for this signature in database

@ -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",

@ -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'
},

Loading…
Cancel
Save