FileEntry.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575
  1. <!--
  2. - @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
  3. -
  4. - @author John Molakvoæ <skjnldsv@protonmail.com>
  5. -
  6. - @license GNU AGPL version 3 or any later version
  7. -
  8. - This program is free software: you can redistribute it and/or modify
  9. - it under the terms of the GNU Affero General Public License as
  10. - published by the Free Software Foundation, either version 3 of the
  11. - License, or (at your option) any later version.
  12. -
  13. - This program is distributed in the hope that it will be useful,
  14. - but WITHOUT ANY WARRANTY; without even the implied warranty of
  15. - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  16. - GNU Affero General Public License for more details.
  17. -
  18. - You should have received a copy of the GNU Affero General Public License
  19. - along with this program. If not, see <http://www.gnu.org/licenses/>.
  20. -
  21. -->
  22. <template>
  23. <Fragment>
  24. <td class="files-list__row-checkbox">
  25. <NcCheckboxRadioSwitch v-if="active"
  26. :aria-label="t('files', 'Select the row for {displayName}', { displayName })"
  27. :checked="selectedFiles"
  28. :value="fileid"
  29. name="selectedFiles"
  30. @update:checked="onSelectionChange" />
  31. </td>
  32. <!-- Link to file -->
  33. <td class="files-list__row-name">
  34. <a ref="name" v-bind="linkTo">
  35. <!-- Icon or preview -->
  36. <span class="files-list__row-icon">
  37. <FolderIcon v-if="source.type === 'folder'" />
  38. <!-- Decorative image, should not be aria documented -->
  39. <span v-else-if="previewUrl && !backgroundFailed"
  40. ref="previewImg"
  41. class="files-list__row-icon-preview"
  42. :style="{ backgroundImage }" />
  43. <span v-else-if="mimeIconUrl"
  44. class="files-list__row-icon-preview files-list__row-icon-preview--mime"
  45. :style="{ backgroundImage: mimeIconUrl }" />
  46. <FileIcon v-else />
  47. </span>
  48. <!-- File name -->
  49. <span class="files-list__row-name-text">{{ displayName }}</span>
  50. </a>
  51. </td>
  52. <!-- Actions -->
  53. <td :class="`files-list__row-actions-${uniqueId}`" class="files-list__row-actions">
  54. <!-- Inline actions -->
  55. <!-- TODO: implement CustomElementRender -->
  56. <!-- Menu actions -->
  57. <NcActions v-if="active"
  58. ref="actionsMenu"
  59. :disabled="source._loading"
  60. :force-title="true"
  61. :inline="enabledInlineActions.length"
  62. :open.sync="openedMenu">
  63. <NcActionButton v-for="action in enabledMenuActions"
  64. :key="action.id"
  65. :class="'files-list__row-action-' + action.id"
  66. @click="onActionClick(action)">
  67. <template #icon>
  68. <NcLoadingIcon v-if="loading === action.id" :size="18" />
  69. <CustomSvgIconRender v-else :svg="action.iconSvgInline([source], currentView)" />
  70. </template>
  71. {{ action.displayName([source], currentView) }}
  72. </NcActionButton>
  73. </NcActions>
  74. </td>
  75. <!-- Size -->
  76. <td v-if="isSizeAvailable"
  77. :style="{ opacity: sizeOpacity }"
  78. class="files-list__row-size">
  79. <span>{{ size }}</span>
  80. </td>
  81. <!-- View columns -->
  82. <td v-for="column in columns"
  83. :key="column.id"
  84. :class="`files-list__row-${currentView?.id}-${column.id}`"
  85. class="files-list__row-column-custom">
  86. <CustomElementRender v-if="active"
  87. :current-view="currentView"
  88. :render="column.render"
  89. :source="source" />
  90. </td>
  91. </Fragment>
  92. </template>
  93. <script lang='ts'>
  94. import { debounce } from 'debounce'
  95. import { formatFileSize } from '@nextcloud/files'
  96. import { Fragment } from 'vue-frag'
  97. import { join } from 'path'
  98. import { showError, showSuccess } from '@nextcloud/dialogs'
  99. import { translate } from '@nextcloud/l10n'
  100. import CancelablePromise from 'cancelable-promise'
  101. import FileIcon from 'vue-material-design-icons/File.vue'
  102. import FolderIcon from 'vue-material-design-icons/Folder.vue'
  103. import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
  104. import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
  105. import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
  106. import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
  107. import Vue from 'vue'
  108. import { getFileActions } from '../services/FileAction.ts'
  109. import { isCachedPreview } from '../services/PreviewService.ts'
  110. import { useActionsMenuStore } from '../store/actionsmenu.ts'
  111. import { useFilesStore } from '../store/files.ts'
  112. import { useKeyboardStore } from '../store/keyboard.ts'
  113. import { useSelectionStore } from '../store/selection.ts'
  114. import { useUserConfigStore } from '../store/userconfig.ts'
  115. import CustomElementRender from './CustomElementRender.vue'
  116. import CustomSvgIconRender from './CustomSvgIconRender.vue'
  117. import logger from '../logger.js'
  118. // The registered actions list
  119. const actions = getFileActions()
  120. export default Vue.extend({
  121. name: 'FileEntry',
  122. components: {
  123. CustomElementRender,
  124. CustomSvgIconRender,
  125. FileIcon,
  126. FolderIcon,
  127. Fragment,
  128. NcActionButton,
  129. NcActions,
  130. NcCheckboxRadioSwitch,
  131. NcLoadingIcon,
  132. },
  133. props: {
  134. active: {
  135. type: Boolean,
  136. default: false,
  137. },
  138. isSizeAvailable: {
  139. type: Boolean,
  140. default: false,
  141. },
  142. source: {
  143. type: Object,
  144. required: true,
  145. },
  146. index: {
  147. type: Number,
  148. required: true,
  149. },
  150. nodes: {
  151. type: Array,
  152. required: true,
  153. },
  154. filesListWidth: {
  155. type: Number,
  156. default: 0,
  157. },
  158. },
  159. setup() {
  160. const actionsMenuStore = useActionsMenuStore()
  161. const filesStore = useFilesStore()
  162. const keyboardStore = useKeyboardStore()
  163. const selectionStore = useSelectionStore()
  164. const userConfigStore = useUserConfigStore()
  165. return {
  166. actionsMenuStore,
  167. filesStore,
  168. keyboardStore,
  169. selectionStore,
  170. userConfigStore,
  171. }
  172. },
  173. data() {
  174. return {
  175. backgroundFailed: false,
  176. backgroundImage: '',
  177. loading: '',
  178. }
  179. },
  180. computed: {
  181. userConfig() {
  182. return this.userConfigStore.userConfig
  183. },
  184. currentView() {
  185. return this.$navigation.active
  186. },
  187. columns() {
  188. // Hide columns if the list is too small
  189. if (this.filesListWidth < 512) {
  190. return []
  191. }
  192. return this.currentView?.columns || []
  193. },
  194. dir() {
  195. // Remove any trailing slash but leave root slash
  196. return (this.$route?.query?.dir || '/').replace(/^(.+)\/$/, '$1')
  197. },
  198. fileid() {
  199. return this.source?.fileid?.toString?.()
  200. },
  201. displayName() {
  202. return this.source.attributes.displayName
  203. || this.source.basename
  204. },
  205. size() {
  206. const size = parseInt(this.source.size, 10) || 0
  207. if (typeof size !== 'number' || size < 0) {
  208. return this.t('files', 'Pending')
  209. }
  210. return formatFileSize(size, true)
  211. },
  212. sizeOpacity() {
  213. const size = parseInt(this.source.size, 10) || 0
  214. if (!size || size < 0) {
  215. return 1
  216. }
  217. // Whatever theme is active, the contrast will pass WCAG AA
  218. // with color main text over main background and an opacity of 0.7
  219. const minOpacity = 0.7
  220. const maxOpacitySize = 10 * 1024 * 1024
  221. return minOpacity + (1 - minOpacity) * Math.pow((this.source.size / maxOpacitySize), 2)
  222. },
  223. linkTo() {
  224. if (this.source.type === 'folder') {
  225. const to = { ...this.$route, query: { dir: join(this.dir, this.source.basename) } }
  226. return {
  227. is: 'router-link',
  228. title: this.t('files', 'Open folder {name}', { name: this.displayName }),
  229. to,
  230. }
  231. }
  232. return {
  233. href: this.source.source,
  234. // TODO: Use first action title ?
  235. title: this.t('files', 'Download file {name}', { name: this.displayName }),
  236. }
  237. },
  238. selectedFiles() {
  239. return this.selectionStore.selected
  240. },
  241. isSelected() {
  242. return this.selectedFiles.includes(this.source?.fileid?.toString?.())
  243. },
  244. cropPreviews() {
  245. return this.userConfig.crop_image_previews
  246. },
  247. previewUrl() {
  248. try {
  249. const url = new URL(window.location.origin + this.source.attributes.previewUrl)
  250. // Request tiny previews
  251. url.searchParams.set('x', '32')
  252. url.searchParams.set('y', '32')
  253. // Handle cropping
  254. url.searchParams.set('a', this.cropPreviews === true ? '1' : '0')
  255. return url.href
  256. } catch (e) {
  257. return null
  258. }
  259. },
  260. mimeIconUrl() {
  261. const mimeType = this.source.mime || 'application/octet-stream'
  262. const mimeIconUrl = window.OC?.MimeType?.getIconUrl?.(mimeType)
  263. if (mimeIconUrl) {
  264. return `url(${mimeIconUrl})`
  265. }
  266. return ''
  267. },
  268. enabledActions() {
  269. return actions
  270. .filter(action => !action.enabled || action.enabled([this.source], this.currentView))
  271. .sort((a, b) => (a.order || 0) - (b.order || 0))
  272. },
  273. enabledInlineActions() {
  274. if (this.filesListWidth < 768) {
  275. return []
  276. }
  277. return this.enabledActions.filter(action => action?.inline?.(this.source, this.currentView))
  278. },
  279. enabledMenuActions() {
  280. if (this.filesListWidth < 768) {
  281. return this.enabledActions
  282. }
  283. return [
  284. ...this.enabledInlineActions,
  285. ...this.enabledActions.filter(action => !action.inline),
  286. ]
  287. },
  288. uniqueId() {
  289. return this.hashCode(this.source.source)
  290. },
  291. openedMenu: {
  292. get() {
  293. return this.actionsMenuStore.opened === this.uniqueId
  294. },
  295. set(opened) {
  296. this.actionsMenuStore.opened = opened ? this.uniqueId : null
  297. },
  298. },
  299. },
  300. watch: {
  301. active(active, before) {
  302. if (active === false && before === true) {
  303. this.resetState()
  304. // When the row is not active anymore
  305. // remove the display from the row to prevent
  306. // keyboard interaction with it.
  307. this.$el.parentNode.style.display = 'none'
  308. return
  309. }
  310. // Restore default tabindex
  311. this.$el.parentNode.style.display = ''
  312. },
  313. /**
  314. * When the source changes, reset the preview
  315. * and fetch the new one.
  316. */
  317. previewUrl() {
  318. this.clearImg()
  319. this.debounceIfNotCached()
  320. },
  321. },
  322. /**
  323. * The row is mounted once and reused as we scroll.
  324. */
  325. mounted() {
  326. // ⚠ Init the debounce function on mount and
  327. // not when the module is imported to
  328. // avoid sharing between recycled components
  329. this.debounceGetPreview = debounce(function() {
  330. this.fetchAndApplyPreview()
  331. }, 150, false)
  332. // Fetch the preview on init
  333. this.debounceIfNotCached()
  334. // Right click watcher on tr
  335. this.$el.parentNode?.addEventListener?.('contextmenu', this.onRightClick)
  336. },
  337. beforeDestroy() {
  338. this.resetState()
  339. },
  340. methods: {
  341. async debounceIfNotCached() {
  342. if (!this.previewUrl) {
  343. return
  344. }
  345. // Check if we already have this preview cached
  346. const isCached = await isCachedPreview(this.previewUrl)
  347. if (isCached) {
  348. this.backgroundImage = `url(${this.previewUrl})`
  349. this.backgroundFailed = false
  350. return
  351. }
  352. // We don't have this preview cached or it expired, requesting it
  353. this.debounceGetPreview()
  354. },
  355. fetchAndApplyPreview() {
  356. // Ignore if no preview
  357. if (!this.previewUrl) {
  358. return
  359. }
  360. // If any image is being processed, reset it
  361. if (this.previewPromise) {
  362. this.clearImg()
  363. }
  364. // Store the promise to be able to cancel it
  365. this.previewPromise = new CancelablePromise((resolve, reject, onCancel) => {
  366. const img = new Image()
  367. // If active, load the preview with higher priority
  368. img.fetchpriority = this.active ? 'high' : 'auto'
  369. img.onload = () => {
  370. this.backgroundImage = `url(${this.previewUrl})`
  371. this.backgroundFailed = false
  372. resolve(img)
  373. }
  374. img.onerror = () => {
  375. this.backgroundFailed = true
  376. reject(img)
  377. }
  378. img.src = this.previewUrl
  379. // Image loading has been canceled
  380. onCancel(() => {
  381. img.onerror = null
  382. img.onload = null
  383. img.src = ''
  384. })
  385. })
  386. },
  387. resetState() {
  388. // Reset loading state
  389. this.loading = ''
  390. // Reset the preview
  391. this.clearImg()
  392. // Close menu
  393. this.openedMenu = false
  394. },
  395. clearImg() {
  396. this.backgroundImage = ''
  397. this.backgroundFailed = false
  398. if (this.previewPromise) {
  399. this.previewPromise.cancel()
  400. this.previewPromise = null
  401. }
  402. },
  403. hashCode(str) {
  404. let hash = 0
  405. for (let i = 0, len = str.length; i < len; i++) {
  406. const chr = str.charCodeAt(i)
  407. hash = (hash << 5) - hash + chr
  408. hash |= 0 // Convert to 32bit integer
  409. }
  410. return hash
  411. },
  412. async onActionClick(action) {
  413. const displayName = action.displayName([this.source], this.currentView)
  414. try {
  415. // Set the loading marker
  416. this.loading = action.id
  417. Vue.set(this.source, '_loading', true)
  418. const success = await action.exec(this.source, this.currentView)
  419. if (success) {
  420. showSuccess(this.t('files', '"{displayName}" action executed successfully', { displayName }))
  421. return
  422. }
  423. showError(this.t('files', '"{displayName}" action failed', { displayName }))
  424. } catch (e) {
  425. logger.error('Error while executing action', { action, e })
  426. showError(this.t('files', '"{displayName}" action failed', { displayName }))
  427. } finally {
  428. // Reset the loading marker
  429. this.loading = ''
  430. Vue.set(this.source, '_loading', false)
  431. }
  432. },
  433. onSelectionChange(selection) {
  434. const newSelectedIndex = this.index
  435. const lastSelectedIndex = this.selectionStore.lastSelectedIndex
  436. // Get the last selected and select all files in between
  437. if (this.keyboardStore?.shiftKey && lastSelectedIndex !== null) {
  438. const isAlreadySelected = this.selectedFiles.includes(this.fileid)
  439. const start = Math.min(newSelectedIndex, lastSelectedIndex)
  440. const end = Math.max(lastSelectedIndex, newSelectedIndex)
  441. const lastSelection = this.selectionStore.lastSelection
  442. const filesToSelect = this.nodes
  443. .map(file => file.fileid?.toString?.())
  444. .slice(start, end + 1)
  445. // If already selected, update the new selection _without_ the current file
  446. const selection = [...lastSelection, ...filesToSelect]
  447. .filter(fileId => !isAlreadySelected || fileId !== this.fileid)
  448. logger.debug('Shift key pressed, selecting all files in between', { start, end, filesToSelect, isAlreadySelected })
  449. // Keep previous lastSelectedIndex to be use for further shift selections
  450. this.selectionStore.set(selection)
  451. return
  452. }
  453. logger.debug('Updating selection', { selection })
  454. this.selectionStore.set(selection)
  455. this.selectionStore.setLastIndex(newSelectedIndex)
  456. },
  457. // Open the actions menu on right click
  458. onRightClick(event) {
  459. // If already opened, fallback to default browser
  460. if (this.openedMenu) {
  461. return
  462. }
  463. // If the clicked row is in the selection, open global menu
  464. const isMoreThanOneSelected = this.selectedFiles.length > 1
  465. this.actionsMenuStore.opened = this.isSelected && isMoreThanOneSelected ? 'global' : this.uniqueId
  466. // Prevent any browser defaults
  467. event.preventDefault()
  468. event.stopPropagation()
  469. },
  470. t: translate,
  471. formatFileSize,
  472. },
  473. })
  474. </script>
  475. <style scoped lang='scss'>
  476. /* Hover effect on tbody lines only */
  477. tr {
  478. &:hover,
  479. &:focus,
  480. &:active {
  481. background-color: var(--color-background-dark);
  482. }
  483. }
  484. /* Preview not loaded animation effect */
  485. .files-list__row-icon-preview:not([style*='background']) {
  486. background: var(--color-loading-dark);
  487. // animation: preview-gradient-fade 1.2s ease-in-out infinite;
  488. }
  489. </style>
  490. <style>
  491. /* @keyframes preview-gradient-fade {
  492. 0% {
  493. opacity: 1;
  494. }
  495. 50% {
  496. opacity: 0.5;
  497. }
  498. 100% {
  499. opacity: 1;
  500. }
  501. } */
  502. </style>