diff --git a/lib/components/FilePicker/FileListRow.vue b/lib/components/FilePicker/FileListRow.vue index 461b3359d..4f61016b5 100644 --- a/lib/components/FilePicker/FileListRow.vue +++ b/lib/components/FilePicker/FileListRow.vue @@ -21,7 +21,7 @@
-
+
@@ -35,11 +35,15 @@ + + diff --git a/lib/usables/preview.spec.ts b/lib/usables/preview.spec.ts new file mode 100644 index 000000000..a8a110238 --- /dev/null +++ b/lib/usables/preview.spec.ts @@ -0,0 +1,103 @@ +/** + * @copyright Copyright (c) 2023 Ferdinand Thiessen + * + * @author Ferdinand Thiessen + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { getPreviewURL, usePreviewURL } from './preview' +import { File } from '@nextcloud/files' +import { defineComponent, h, toRef } from 'vue' +import { shallowMount } from '@vue/test-utils' + +describe('preview composable', () => { + const createData = (path: string, mime: string) => ({ + owner: null, + source: `http://example.com/dav/${path}`, + mime, + mtime: new Date(), + root: '/', + }) + + describe('previewURL', () => { + beforeAll(() => { + vi.useFakeTimers() + }) + afterAll(() => { + vi.useRealTimers() + }) + + it('is reactive', async () => { + const text = new File({ + ...createData('text.txt', 'text/plain'), + id: 1, + }) + const image = new File({ + ...createData('image.png', 'image/png'), + id: 2, + }) + + const wrapper = shallowMount(defineComponent({ + props: ['node'], + setup(props) { + const { previewURL } = usePreviewURL(toRef(props, 'node')) + return () => h('div', previewURL.value?.href) + }, + }), { + propsData: { node: text }, + }) + + expect(wrapper.text()).toMatch('/core/preview?fileId=1') + await wrapper.setProps({ node: image }) + expect(wrapper.text()).toMatch('/core/preview?fileId=2') + }) + + it('uses Nodes previewUrl if available', () => { + const previewNode = new File({ + ...createData('text.txt', 'text/plain'), + attributes: { + previewUrl: '/preview.svg', + }, + }) + const { previewURL } = usePreviewURL(previewNode) + + expect(previewURL.value?.pathname).toBe('/preview.svg') + }) + + it('works with full URL previewUrl', () => { + const previewNode = new File({ + ...createData('text.txt', 'text/plain'), + attributes: { + previewUrl: 'http://example.com/preview.svg', + }, + }) + const { previewURL } = usePreviewURL(previewNode) + + expect(previewURL.value?.href.startsWith('http://example.com/preview.svg?')).toBe(true) + }) + + it('supports options', () => { + const previewNode = new File(createData('text.txt', 'text/plain')) + + expect(getPreviewURL(previewNode, { size: 16 })?.searchParams.get('x')).toBe('16') + expect(getPreviewURL(previewNode, { size: 16 })?.searchParams.get('y')).toBe('16') + + expect(getPreviewURL(previewNode, { mimeFallback: false })?.searchParams.get('mimeFallback')).toBe('false') + }) + }) +}) diff --git a/lib/usables/preview.ts b/lib/usables/preview.ts new file mode 100644 index 000000000..90c69cb61 --- /dev/null +++ b/lib/usables/preview.ts @@ -0,0 +1,92 @@ +/** + * @copyright Copyright (c) 2023 Ferdinand Thiessen + * + * @author Ferdinand Thiessen + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import type { Node } from '@nextcloud/files' +import type { Ref } from 'vue' + +import { generateUrl } from '@nextcloud/router' +import { toValue } from '@vueuse/core' +import { ref, watchEffect } from 'vue' + +interface PreviewOptions { + /** + * Size of the previews in px + * @default 32 + */ + size?: number + /** + * Should the preview fall back to the mime type icon + * @default true + */ + mimeFallback?: boolean + /** + * Should the preview be cropped or fitted + * @default false (meaning it gets fitted) + */ + cropPreview?: boolean +} + +/** + * Generate the preview URL of a file node + * + * @param node The node to generate the preview for + * @param options Preview options + */ +export function getPreviewURL(node: Node, options: PreviewOptions = {}) { + options = { size: 32, cropPreview: false, mimeFallback: true, ...options } + + try { + const previewUrl = node.attributes?.previewUrl + || generateUrl('/core/preview?fileId={fileid}', { + fileid: node.fileid, + }) + + let url + try { + url = new URL(previewUrl) + } catch (e) { + url = new URL(previewUrl, window.location.origin) + } + + // Request preview with params + url.searchParams.set('x', `${options.size}`) + url.searchParams.set('y', `${options.size}`) + url.searchParams.set('mimeFallback', `${options.mimeFallback}`) + + // Handle cropping + url.searchParams.set('a', options.cropPreview === true ? '0' : '1') + return url + } catch (e) { + return null + } +} + +export const usePreviewURL = (node: Node | Ref, options?: PreviewOptions | Ref) => { + const previewURL = ref(null) + + watchEffect(() => { + previewURL.value = getPreviewURL(toValue(node), toValue(options || {})) + }) + + return { + previewURL, + } +} diff --git a/package-lock.json b/package-lock.json index 572f3c50f..3df5368cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,19 +10,19 @@ "hasInstallScript": true, "license": "GPL-3.0-or-later", "dependencies": { - "@mdi/svg": "^7.2.96", "@nextcloud/files": "^3.0.0-beta.19", "@nextcloud/l10n": "^2.2.0", + "@nextcloud/router": "^2.1.2", "@nextcloud/typings": "^1.7.0", "@nextcloud/vue": "^8.0.0-beta.4", "@types/toastify-js": "^1.12.0", "@vueuse/core": "^10.4.0", "toastify-js": "^1.12.0", "vue-frag": "^1.4.3", - "vue-material-design-icons": "^5.2.0", "webdav": "^5.2.3" }, "devDependencies": { + "@mdi/svg": "^7.2.96", "@nextcloud/browserslist-config": "^3.0.0", "@nextcloud/eslint-config": "^8.3.0-beta.2", "@nextcloud/vite-config": "^1.0.0-beta.18", @@ -40,7 +40,8 @@ "typedoc": "^0.25.0", "typescript": "^5.1.6", "vite": "^4.4.9", - "vitest": "^0.34.3" + "vitest": "^0.34.3", + "vue-material-design-icons": "^5.2.0" }, "engines": { "node": "^20.0.0", @@ -2714,7 +2715,8 @@ "node_modules/@mdi/svg": { "version": "7.2.96", "resolved": "https://registry.npmjs.org/@mdi/svg/-/svg-7.2.96.tgz", - "integrity": "sha512-rxzuSL2RSt/pWWnFnUFQi5GJArm2tHMhx20Gee3Ydn+xT2bqbR4syfgdPrq2b+j+n5LjC7C8Fb1QDM6LKeF0cA==" + "integrity": "sha512-rxzuSL2RSt/pWWnFnUFQi5GJArm2tHMhx20Gee3Ydn+xT2bqbR4syfgdPrq2b+j+n5LjC7C8Fb1QDM6LKeF0cA==", + "dev": true }, "node_modules/@microsoft/api-extractor": { "version": "7.36.4", diff --git a/package.json b/package.json index c2a0d9e51..a5a71d79b 100644 --- a/package.json +++ b/package.json @@ -56,19 +56,19 @@ "vue": "^2.7.14" }, "dependencies": { - "@mdi/svg": "^7.2.96", "@nextcloud/files": "^3.0.0-beta.19", "@nextcloud/l10n": "^2.2.0", + "@nextcloud/router": "^2.1.2", "@nextcloud/typings": "^1.7.0", "@nextcloud/vue": "^8.0.0-beta.4", "@types/toastify-js": "^1.12.0", "@vueuse/core": "^10.4.0", "toastify-js": "^1.12.0", "vue-frag": "^1.4.3", - "vue-material-design-icons": "^5.2.0", "webdav": "^5.2.3" }, "devDependencies": { + "@mdi/svg": "^7.2.96", "@nextcloud/browserslist-config": "^3.0.0", "@nextcloud/eslint-config": "^8.3.0-beta.2", "@nextcloud/vite-config": "^1.0.0-beta.18", @@ -86,7 +86,8 @@ "typedoc": "^0.25.0", "typescript": "^5.1.6", "vite": "^4.4.9", - "vitest": "^0.34.3" + "vitest": "^0.34.3", + "vue-material-design-icons": "^5.2.0" }, "engines": { "node": "^20.0.0", diff --git a/vite.config.ts b/vite.config.ts index 63476549d..71a6cdc03 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -29,7 +29,10 @@ export default defineConfig((env) => { }, }, nodeExternalsOptions: { + // for subpath imports like '@nextcloud/l10n/gettext' include: [/^@nextcloud\//], + // we should externalize vue SFC dependencies + exclude: [/^vue-material-design-icons\//, /\.vue(\?|$)/], }, libraryFormats: ['es', 'cjs'], replace: {