From 7f9df5d8c35b9ae496f3df7f07d66b868d77a8b2 Mon Sep 17 00:00:00 2001 From: "Enjeck C." Date: Wed, 2 Sep 2026 07:39:55 +0100 Subject: [PATCH] feat: Allow image previews of Files image links Signed-off-by: Enjeck C. --- lib/Service/ColumnTypes/TextLinkBusiness.php | 17 ++- src/modules/modals/CreateColumn.vue | 17 ++- src/modules/modals/EditColumn.vue | 23 +++- .../ncTable/partials/TableCellLink.vue | 119 +++++++++++++++++- .../columnTypePartials/forms/TextLinkForm.vue | 110 +++++++++++++--- src/shared/constants.ts | 4 + src/shared/utils/imagePreviewSize.js | 33 +++++ .../ColumnTypes/TextLinkBusinessTest.php | 27 ++++ 8 files changed, 328 insertions(+), 22 deletions(-) create mode 100644 src/shared/utils/imagePreviewSize.js diff --git a/lib/Service/ColumnTypes/TextLinkBusiness.php b/lib/Service/ColumnTypes/TextLinkBusiness.php index ca5ece0eb2..32624385f7 100644 --- a/lib/Service/ColumnTypes/TextLinkBusiness.php +++ b/lib/Service/ColumnTypes/TextLinkBusiness.php @@ -48,11 +48,24 @@ public function parseValue($value, Column $column): string|false { $data = json_decode((string)$value, true); if ($data !== null) { if (isset($data['resourceUrl'])) { - return json_encode(json_encode([ + $parsed = [ 'title' => $data['title'] ?? $data['resourceUrl'], 'value' => $data['resourceUrl'], 'providerId' => $data['providerId'] ?? 'url', - ])); + ]; + $settings = json_decode($column->getCustomSettings() ?? '', true); + if (is_array($settings) && !empty($settings['showPreview'])) { + if (!empty($data['thumbnailUrl'])) { + $parsed['thumbnailUrl'] = $data['thumbnailUrl']; + } + if (!empty($data['icon'])) { + $parsed['icon'] = $data['icon']; + } + if (isset($data['attributes']) && is_array($data['attributes'])) { + $parsed['attributes'] = $data['attributes']; + } + } + return json_encode(json_encode($parsed)); } // at least title and resUrl have to be set if (isset($data['title']) && isset($data['value'])) { diff --git a/src/modules/modals/CreateColumn.vue b/src/modules/modals/CreateColumn.vue index b6095ffd7f..e59d2905fc 100644 --- a/src/modules/modals/CreateColumn.vue +++ b/src/modules/modals/CreateColumn.vue @@ -130,8 +130,14 @@ import RelationForm from '../../shared/components/ncTable/partials/columnTypePar import { useTablesStore } from '../../store/store.js' import { useDataStore } from '../../store/data.js' import { mapActions } from 'pinia' -import { COLUMN_WIDTH_MAX, COLUMN_WIDTH_MIN } from '../../shared/constants.js' +import { + COLUMN_WIDTH_MAX, + COLUMN_WIDTH_MIN, + IMAGE_PREVIEW_SIZE_MAX, + IMAGE_PREVIEW_SIZE_MIN, +} from '../../shared/constants.js' import { normalizeTechnicalName, isTechnicalNameValid } from '../../shared/utils/columnUtils.js' +import { isImagePreviewSizeValid, normalizeImagePreviewSize } from '../../shared/utils/imagePreviewSize.js' export default { name: 'CreateColumn', @@ -323,6 +329,11 @@ export default { && (this.column.customSettings?.width < COLUMN_WIDTH_MIN || this.column.customSettings?.width > COLUMN_WIDTH_MAX)) { showError(t('tables', 'Cannot save column. Column width must be between {min} and {max}.', { min: COLUMN_WIDTH_MIN, max: COLUMN_WIDTH_MAX })) this.widthInvalidError = true + } else if (this.combinedType === ColumnTypes.TextLink + && this.column.customSettings?.showPreview + && this.column.customSettings?.imagePreviewSize !== undefined + && !isImagePreviewSizeValid(this.column.customSettings?.imagePreviewSize)) { + showError(t('tables', 'Cannot save column. Image preview size must be between {min} and {max}.', { min: IMAGE_PREVIEW_SIZE_MIN, max: IMAGE_PREVIEW_SIZE_MAX })) } else if (this.column.type === null) { this.titleMissingError = false showInfo(t('tables', 'You need to select a type for the new column.')) @@ -382,6 +393,10 @@ export default { data.textDefault = this.column.textDefault } else if (this.combinedType === ColumnTypes.TextLink) { data.textAllowedPattern = this.column.textAllowedPattern + data.customSettings.showPreview = !!this.column.customSettings?.showPreview + if (data.customSettings.showPreview || this.column.customSettings?.imagePreviewSize !== undefined) { + data.customSettings.imagePreviewSize = normalizeImagePreviewSize(this.column.customSettings?.imagePreviewSize) + } } else if (this.column.type === 'selection') { data.selectionDefault = typeof this.column.selectionDefault !== 'string' ? JSON.stringify(this.column.selectionDefault) : this.column.selectionDefault if (this.column.subtype !== 'check') { diff --git a/src/modules/modals/EditColumn.vue b/src/modules/modals/EditColumn.vue index da4d90ade9..9140f96ab8 100644 --- a/src/modules/modals/EditColumn.vue +++ b/src/modules/modals/EditColumn.vue @@ -75,8 +75,14 @@ import { ColumnTypes } from '../../shared/components/ncTable/mixins/columnHandle import moment from '@nextcloud/moment' import { mapActions } from 'pinia' import { useDataStore } from '../../store/data.js' -import { COLUMN_WIDTH_MAX, COLUMN_WIDTH_MIN } from '../../shared/constants.js' +import { + COLUMN_WIDTH_MAX, + COLUMN_WIDTH_MIN, + IMAGE_PREVIEW_SIZE_MAX, + IMAGE_PREVIEW_SIZE_MIN, +} from '../../shared/constants.js' import { normalizeTechnicalName, isTechnicalNameValid } from '../../shared/utils/columnUtils.js' +import { isImagePreviewSizeValid, normalizeImagePreviewSize } from '../../shared/utils/imagePreviewSize.js' export default { name: 'EditColumn', @@ -192,6 +198,14 @@ export default { return } + if (this.isTextLinkColumn(this.editColumn) + && this.editColumn.customSettings?.showPreview + && this.editColumn.customSettings?.imagePreviewSize !== undefined + && !isImagePreviewSizeValid(this.editColumn.customSettings?.imagePreviewSize)) { + showError(t('tables', 'Cannot save column. Image preview size must be between {min} and {max}.', { min: IMAGE_PREVIEW_SIZE_MIN, max: IMAGE_PREVIEW_SIZE_MAX })) + return + } + await this.updateLocalColumn() this.reset() this.$emit('close') @@ -221,6 +235,10 @@ export default { data.technicalName = this.normalizeTechnicalName(data.technicalName) data.customSettings = { ...data.customSettings, width: data.customSettings.width } + if (this.isTextLinkColumn(this.column) + && (data.customSettings.showPreview || data.customSettings.imagePreviewSize !== undefined)) { + data.customSettings.imagePreviewSize = normalizeImagePreviewSize(data.customSettings.imagePreviewSize) + } const res = await this.updateColumn({ id: this.editColumn.id, isView: this.isView, @@ -238,6 +256,9 @@ export default { isTechnicalNameValid() { return isTechnicalNameValid(this.editColumn.technicalName) }, + isTextLinkColumn(column) { + return column?.type === ColumnTypes.TextLink || (column?.type === 'text' && column?.subtype === 'link') + }, }, } diff --git a/src/shared/components/ncTable/partials/TableCellLink.vue b/src/shared/components/ncTable/partials/TableCellLink.vue index 5784713a1c..a412fb029e 100644 --- a/src/shared/components/ncTable/partials/TableCellLink.vue +++ b/src/shared/components/ncTable/partials/TableCellLink.vue @@ -8,7 +8,19 @@ @click="handleStartEditing" @keydown.enter="handleStartEditing" @keydown.space.prevent="handleStartEditing"> - + + + import { NcTextField, NcSelect } from '@nextcloud/vue' import axios from '@nextcloud/axios' -import { generateOcsUrl } from '@nextcloud/router' +import { generateOcsUrl, generateUrl } from '@nextcloud/router' import { translate as t } from '@nextcloud/l10n' import debounce from 'debounce' import generalHelper from '../../../mixins/generalHelper.js' @@ -68,6 +80,7 @@ import displayError from '../../../utils/displayError.js' import { showError } from '@nextcloud/dialogs' import LinkWidget from './LinkWidget.vue' import { ALLOWED_PROTOCOLS } from '../../../constants.ts' +import { normalizeImagePreviewSize } from '../../../utils/imagePreviewSize.js' export default { name: 'TableCellLink', @@ -103,10 +116,79 @@ export default { providerLoading: {}, isInitialEditClick: false, allowedProtocols: ALLOWED_PROTOCOLS, + imagePreviewFailed: false, } }, computed: { + fileId() { + const valueObject = this.getValueObject + if (valueObject?.providerId !== 'files') { + return null + } + + if (valueObject?.attributes?.fileId) { + return valueObject.attributes.fileId + } + + const url = valueObject?.resourceUrl || valueObject?.value || '' + const match = url.match(/\/f\/(\d+)(?:[/?#]|$)/) || url.match(/[?&]fileid=(\d+)/) || url.match(/[?&]openfile=(\d+)/) + return match ? match[1] : null + }, + showImagePreview() { + return !!this.imagePreviewSrc && !this.imagePreviewFailed + }, + linkWidgetThumbnailUrl() { + return this.imagePreviewFailed ? null : this.getValueObject.thumbnailUrl + }, + imagePreviewSize() { + return normalizeImagePreviewSize(this.column?.customSettings?.imagePreviewSize) + }, + imagePreviewStyle() { + return { + '--image-preview-size': this.imagePreviewSize + 'px', + } + }, + imagePreviewSrc() { + if (!this.column?.customSettings?.showPreview || !this.isFilesImageLink) { + return null + } + + if (this.fileId) { + const previewParameters = new URLSearchParams({ + fileId: String(this.fileId), + x: String(this.imagePreviewSize), + y: String(this.imagePreviewSize), + a: '1', + }) + return generateUrl('/core/preview') + '?' + previewParameters.toString() + } + if (this.getValueObject?.thumbnailUrl) { + return this.getValueObject.thumbnailUrl + } + return null + }, + imagePreviewLink() { + return this.getValueObject?.resourceUrl || this.getValueObject?.value + }, + imagePreviewLabel() { + return this.getValueObject?.title || t('tables', 'Image preview') + }, + isFilesImageLink() { + if (this.getValueObject?.providerId !== 'files') { + return false + } + + return this.imageFileNameCandidates.some(candidate => this.hasImageExtension(candidate)) + }, + imageFileNameCandidates() { + return [ + this.getValueObject?.title, + this.getValueObject?.attributes?.path, + this.getValueObject?.resourceUrl, + this.getValueObject?.value, + ].filter(Boolean) + }, getValueObject() { if (this.hasJsonStructure(this.value)) { const valueObject = JSON.parse(this.value) @@ -194,6 +276,9 @@ export default { this.isInitialEditClick = false } }, + imagePreviewSrc() { + this.imagePreviewFailed = false + }, }, mounted() { @@ -331,6 +416,19 @@ export default { this.results = this.results.filter(item => item.providerId !== providerId) }, + hasImageExtension(value) { + try { + const { pathname } = new URL(value, window.location.origin) + return /\.(apng|avif|bmp|gif|heic|heif|ico|jpe?g|png|svg|tiff?|webp)$/i.test(pathname) + } catch (e) { + return /\.(apng|avif|bmp|gif|heic|heif|ico|jpe?g|png|svg|tiff?|webp)$/i.test(value) + } + }, + + handlePreviewError() { + this.imagePreviewFailed = true + }, + async saveChanges() { if (this.localLoading) { return @@ -377,6 +475,23 @@ export default {