Skip to content

Commit

Permalink
fix: Use core preview controller for loading file previews and fallba…
Browse files Browse the repository at this point in the history
…ck to MDI icons

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
  • Loading branch information
susnux committed Aug 27, 2023
1 parent 69ad471 commit 15c0215
Show file tree
Hide file tree
Showing 7 changed files with 269 additions and 23 deletions.
22 changes: 6 additions & 16 deletions lib/components/FilePicker/FileListRow.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
</td>
<td class="row-name">
<div class="file-picker__name-container" data-testid="row-name">
<div class="file-picker__file-icon" :style="{ backgroundImage }" />
<FilePreview :node="node" />
<div class="file-picker__file-name" :title="displayName" v-text="displayName" />
<div class="file-picker__file-extension" v-text="fileExtension" />
</div>
Expand All @@ -35,11 +35,15 @@
</tr>
</template>
<script setup lang="ts">
import { type Node, formatFileSize, FileType } from '@nextcloud/files'
import type { Node } from '@nextcloud/files'
import { formatFileSize, FileType } from '@nextcloud/files'
import { NcCheckboxRadioSwitch, NcDatetime } from '@nextcloud/vue'
import { computed } from 'vue'
import { t } from '../../utils/l10n'
import FilePreview from './FilePreview.vue'
const props = defineProps<{
/** Can directories be picked */
allowPickDirectory: boolean
Expand Down Expand Up @@ -80,11 +84,6 @@ const isDirectory = computed(() => props.node.type === FileType.Folder)
*/
const isPickable = computed(() => props.canPick && (props.allowPickDirectory || !isDirectory.value))
/**
* Background image url for the given nodes mime type
*/
const backgroundImage = computed(() => `url(${window.OC.MimeType.getIconUrl(props.node.mime)})`)
/**
* Toggle the selection state
*/
Expand Down Expand Up @@ -134,15 +133,6 @@ function handleKeyDown(event: KeyboardEvent) {
height: 100%;
}
&__file-icon {
width: 32px;
height: 32px;
min-width: 32px;
min-height: 32px;
background-repeat: no-repeat;
background-size: contain;
}
&__file-name {
padding-inline-start: 6px;
min-width: 0;
Expand Down
55 changes: 55 additions & 0 deletions lib/components/FilePicker/FilePreview.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<template>
<div :style="canLoadPreview ? { backgroundImage: `url(${previewURL})`} : undefined"
:aria-label="t('Mime type {mime}', { mime: node.mime || t('unknown') })"
class="file-picker__file-icon">
<template v-if="!canLoadPreview">
<IconFile v-if="isFile" :size="20" />
<IconFolder v-else :size="20" />
</template>
</div>
</template>

<script setup lang="ts">
import { FileType, type Node } from '@nextcloud/files'
import { usePreviewURL } from '../../usables/preview'
import { computed, ref, toRef, watch } from 'vue'
import { t } from '../../utils/l10n'
import IconFile from 'vue-material-design-icons/File.vue'
import IconFolder from 'vue-material-design-icons/Folder.vue'
const props = defineProps<{
node: Node
}>()
const { previewURL } = usePreviewURL(toRef(props, 'node'))
const isFile = computed(() => props.node.type === FileType.File)
const canLoadPreview = ref(false)
watch(previewURL, () => {
canLoadPreview.value = false
if (previewURL.value) {
const loader = document.createElement('img')
loader.src = previewURL.value.href
loader.onerror = () => loader.remove()
loader.onload = () => { canLoadPreview.value = true; loader.remove() }
document.body.appendChild(loader)
}
}, { immediate: true })
</script>

<style scoped lang="scss">
.file-picker__file-icon {
width: 32px;
height: 32px;
min-width: 32px;
min-height: 32px;
background-repeat: no-repeat;
background-size: contain;
// for the fallback
display: flex;
justify-content: center;
}
</style>
103 changes: 103 additions & 0 deletions lib/usables/preview.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de>
*
* @author Ferdinand Thiessen <opensource@fthiessen.de>
*
* @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 <http://www.gnu.org/licenses/>.
*/

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')
})
})
})
92 changes: 92 additions & 0 deletions lib/usables/preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de>
*
* @author Ferdinand Thiessen <opensource@fthiessen.de>
*
* @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 <http://www.gnu.org/licenses/>.
*/

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<Node>, options?: PreviewOptions | Ref<PreviewOptions>) => {
const previewURL = ref<URL|null>(null)

watchEffect(() => {
previewURL.value = getPreviewURL(toValue(node), toValue(options || {}))
})

return {
previewURL,
}
}
10 changes: 6 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down

0 comments on commit 15c0215

Please sign in to comment.