feat: rework preview and editor

fix filebrowser/filebrowser#750, fix filebrowser/filebrowser#553
Preview:
- Opens and cycle all files types.
- Support for text, mardown and html.
- Added filename title, esc key shortcut and editor button.
Editor:
- Shows on full screen overlay.
- Added esc key shortcut.
This commit is contained in:
Ramires Viana 2020-01-10 00:50:46 +00:00
parent 77eef5dc12
commit b0693906eb
14 changed files with 160 additions and 49 deletions

View File

@ -13,6 +13,7 @@
"clipboard": "^2.0.4", "clipboard": "^2.0.4",
"js-base64": "^2.5.1", "js-base64": "^2.5.1",
"lodash.clonedeep": "^4.5.0", "lodash.clonedeep": "^4.5.0",
"marked": "^0.8.0",
"material-design-icons": "^3.0.1", "material-design-icons": "^3.0.1",
"moment": "^2.24.0", "moment": "^2.24.0",
"normalize.css": "^8.0.1", "normalize.css": "^8.0.1",

View File

@ -124,6 +124,12 @@ nav > div {
color: var(--textPrimary); color: var(--textPrimary);
} }
#previewer .text,
#previewer .markdown,
#previewer .html {
background: var(--surfacePrimary);
}
@media (max-width: 736px) { @media (max-width: 736px) {
#file-selection { #file-selection {
background: var(--surfaceSecondary) !important; background: var(--surfaceSecondary) !important;

View File

@ -8,7 +8,7 @@
<search v-if="isLogged"></search> <search v-if="isLogged"></search>
</div> </div>
<div> <div>
<template v-if="isLogged"> <template v-if="isLogged && isListing">
<button @click="openSearch" :aria-label="$t('buttons.search')" :title="$t('buttons.search')" class="search-button action"> <button @click="openSearch" :aria-label="$t('buttons.search')" :title="$t('buttons.search')" class="search-button action">
<i class="material-icons">search</i> <i class="material-icons">search</i>
</button> </button>

View File

@ -0,0 +1,17 @@
<template>
<button @click="show" :aria-label="$t('buttons.edit')" :title="$t('buttons.edit')" class="action" id="edit-button">
<i class="material-icons">mode_edit</i>
<span>{{ $t('buttons.edit') }}</span>
</button>
</template>
<script>
export default {
name: 'edit-button',
methods: {
show: function () {
this.$store.commit('toggleEditor')
}
}
}
</script>

View File

@ -1,6 +1,6 @@
<template> <template>
<button @click="show" :aria-label="$t('buttons.rename')" :title="$t('buttons.rename')" class="action" id="rename-button"> <button @click="show" :aria-label="$t('buttons.rename')" :title="$t('buttons.rename')" class="action" id="rename-button">
<i class="material-icons">mode_edit</i> <i class="material-icons">label</i>
<span>{{ $t('buttons.rename') }}</span> <span>{{ $t('buttons.rename') }}</span>
</button> </button>
</template> </template>

View File

@ -1,5 +1,18 @@
<template> <template>
<form id="editor"></form> <div id="previewer">
<div class="bar">
<button @click="back" class="action" :title="$t('files.closePreview')" :aria-label="$t('files.closePreview')" id="close">
<i class="material-icons">close</i>
</button>
<span class="title">{{ req.name + ((contentChanged) ? '*' : '') }}</span>
<button @click="save" v-show="user.perm.modify" :aria-label="$t('buttons.save')" :title="$t('buttons.save')" class="action" id="save-button">
<i class="material-icons">save</i>
</button>
</div>
<div class="editor">
<form id="editor"></form>
</div>
</div>
</template> </template>
<script> <script>
@ -10,55 +23,50 @@ import buttons from '@/utils/buttons'
import ace from 'ace-builds/src-min-noconflict/ace.js' import ace from 'ace-builds/src-min-noconflict/ace.js'
import modelist from 'ace-builds/src-min-noconflict/ext-modelist.js' import modelist from 'ace-builds/src-min-noconflict/ext-modelist.js'
import 'ace-builds/webpack-resolver' import 'ace-builds/webpack-resolver'
import { theme } from '@/utils/constants'
export default { export default {
name: 'editor', name: 'editor',
computed: { computed: {
...mapState(['req']) ...mapState(['req', 'user', 'previewContent'])
}, },
data: function () { data: function () {
return {} return {
editor: null,
contentChanged: false
}
}, },
created () { created () {
window.addEventListener('keydown', this.keyEvent) window.addEventListener('keydown', this.keyEvent)
document.getElementById('save-button').addEventListener('click', this.save)
}, },
beforeDestroy () { beforeDestroy () {
window.removeEventListener('keydown', this.keyEvent) window.removeEventListener('keydown', this.keyEvent)
document.getElementById('save-button').removeEventListener('click', this.save)
this.editor.destroy();
}, },
mounted: function () { mounted: function () {
const fileContent = this.req.content || '';
this.editor = ace.edit('editor', { this.editor = ace.edit('editor', {
maxLines: Infinity, maxLines: Infinity,
minLines: 20, minLines: 20,
value: fileContent, value: this.previewContent,
showPrintMargin: false, showPrintMargin: false,
readOnly: this.req.type === 'textImmutable', readOnly: this.req.type === 'textImmutable',
theme: 'ace/theme/chrome', theme: 'ace/theme/twilight',
mode: modelist.getModeForPath(this.req.name).mode, mode: modelist.getModeForPath(this.req.name).mode,
wrap: true wrap: true
}) })
if (theme == 'dark') { this.editor.on('change', () => {
this.editor.setTheme("ace/theme/twilight"); this.contentChanged = true
} })
}, },
methods: { methods: {
keyEvent (event) { keyEvent (event) {
if (!event.ctrlKey && !event.metaKey) { let key = String.fromCharCode(event.which).toLowerCase()
return
}
if (String.fromCharCode(event.which).toLowerCase() !== 's') { if ((event.ctrlKey || event.metaKey) && key == 's') {
return event.preventDefault()
this.save()
} else if (event.which === 27) { // Esc
this.$store.commit('toggleEditor')
} }
event.preventDefault()
this.save()
}, },
async save () { async save () {
const button = 'save' const button = 'save'
@ -66,11 +74,18 @@ export default {
try { try {
await api.put(this.$route.path, this.editor.getValue()) await api.put(this.$route.path, this.editor.getValue())
this.$store.commit('setPreviewContent', this.editor.getValue())
this.contentChanged = false
buttons.success(button) buttons.success(button)
} catch (e) { } catch (e) {
buttons.done(button) buttons.done(button)
this.$showError(e) this.$showError(e)
} }
},
back () {
this.$store.commit('toggleEditor')
} }
} }
} }

View File

@ -4,7 +4,8 @@
<button @click="back" class="action" :title="$t('files.closePreview')" :aria-label="$t('files.closePreview')" id="close"> <button @click="back" class="action" :title="$t('files.closePreview')" :aria-label="$t('files.closePreview')" id="close">
<i class="material-icons">close</i> <i class="material-icons">close</i>
</button> </button>
<span class="title">{{ req.name }}</span>
<edit-button v-if="isFileEditable"></edit-button>
<rename-button v-if="user.perm.rename"></rename-button> <rename-button v-if="user.perm.rename"></rename-button>
<delete-button v-if="user.perm.delete"></delete-button> <delete-button v-if="user.perm.delete"></delete-button>
<download-button v-if="user.perm.download"></download-button> <download-button v-if="user.perm.download"></download-button>
@ -33,29 +34,37 @@
and watch it with your favorite video player! and watch it with your favorite video player!
</video> </video>
<object v-else-if="req.extension == '.pdf'" class="pdf" :data="raw"></object> <object v-else-if="req.extension == '.pdf'" class="pdf" :data="raw"></object>
<a v-else-if="req.type == 'blob'" :href="download"> <div class="html" v-else-if="req.extension == '.html'"> <iframe :src="getHtmlContent()"></iframe> </div>
<h2 class="message">{{ $t('buttons.download') }} <i class="material-icons">file_download</i></h2> <div class="markdown" v-else-if="isMarkdown" v-html="getMarkdownContent()"></div>
</a> <div class="text" v-else-if="req.type == 'text'">{{ previewContent }}</div>
<div v-else>
<a :href="download">
<h2 class="message">{{ $t('buttons.download') }} <i class="material-icons">file_download</i></h2>
</a>
</div>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import { mapState } from 'vuex' import { mapGetters, mapState } from 'vuex'
import url from '@/utils/url' import url from '@/utils/url'
import { baseURL } from '@/utils/constants' import { baseURL } from '@/utils/constants'
import { files as api } from '@/api' import { files as api } from '@/api'
import marked from 'marked'
import InfoButton from '@/components/buttons/Info' import InfoButton from '@/components/buttons/Info'
import DeleteButton from '@/components/buttons/Delete' import DeleteButton from '@/components/buttons/Delete'
import RenameButton from '@/components/buttons/Rename' import RenameButton from '@/components/buttons/Rename'
import DownloadButton from '@/components/buttons/Download' import DownloadButton from '@/components/buttons/Download'
import EditButton from '@/components/buttons/Edit'
import ExtendedImage from './ExtendedImage' import ExtendedImage from './ExtendedImage'
const mediaTypes = [ const markdownExtesions = [
"image", '.md',
"video", '.mkdn',
"audio", '.mdwn',
"blob" '.mdown',
'.markdown'
] ]
export default { export default {
@ -65,6 +74,7 @@ export default {
DeleteButton, DeleteButton,
RenameButton, RenameButton,
DownloadButton, DownloadButton,
EditButton,
ExtendedImage ExtendedImage
}, },
data: function () { data: function () {
@ -76,7 +86,8 @@ export default {
} }
}, },
computed: { computed: {
...mapState(['req', 'user', 'oldReq', 'jwt']), ...mapGetters(['isFileEditable']),
...mapState(['req', 'user', 'oldReq', 'jwt', 'previewContent']),
hasPrevious () { hasPrevious () {
return (this.previousLink !== '') return (this.previousLink !== '')
}, },
@ -88,10 +99,13 @@ export default {
}, },
raw () { raw () {
return `${this.download}&inline=true` return `${this.download}&inline=true`
},
isMarkdown () {
return markdownExtesions.includes(this.req.extension)
} }
}, },
async mounted () { async mounted () {
window.addEventListener('keyup', this.key) window.addEventListener('keydown', this.key)
if (this.req.subtitles) { if (this.req.subtitles) {
this.subtitles = this.req.subtitles.map(sub => `${baseURL}/api/raw${sub}?auth=${this.jwt}&inline=true`) this.subtitles = this.req.subtitles.map(sub => `${baseURL}/api/raw${sub}?auth=${this.jwt}&inline=true`)
@ -110,7 +124,7 @@ export default {
} }
}, },
beforeDestroy () { beforeDestroy () {
window.removeEventListener('keyup', this.key) window.removeEventListener('keydown', this.key)
}, },
methods: { methods: {
back () { back () {
@ -130,6 +144,8 @@ export default {
if (this.hasNext) this.next() if (this.hasNext) this.next()
} else if (event.which === 37) { // left arrow } else if (event.which === 37) { // left arrow
if (this.hasPrevious) this.prev() if (this.hasPrevious) this.prev()
} else if (event.which == 27) { // esc
this.back()
} }
}, },
updateLinks (items) { updateLinks (items) {
@ -139,14 +155,14 @@ export default {
} }
for (let j = i - 1; j >= 0; j--) { for (let j = i - 1; j >= 0; j--) {
if (mediaTypes.includes(items[j].type)) { if (!items[j].isDir) {
this.previousLink = items[j].url this.previousLink = items[j].url
break break
} }
} }
for (let j = i + 1; j < items.length; j++) { for (let j = i + 1; j < items.length; j++) {
if (mediaTypes.includes(items[j].type)) { if (!items[j].isDir) {
this.nextLink = items[j].url this.nextLink = items[j].url
break break
} }
@ -154,6 +170,12 @@ export default {
return return
} }
},
getHtmlContent () {
return 'data:text/html;base64,' + btoa(unescape(encodeURIComponent(this.previewContent)))
},
getMarkdownContent () {
return marked(this.previewContent)
} }
} }
} }

View File

@ -125,8 +125,13 @@
height: 3.7em; height: 3.7em;
} }
#previewer .action:first-of-type { #previewer .title {
color: #fff;
font-size: 18px;
line-height: 2.4;
padding: 0 8px;
margin-right: auto; margin-right: auto;
overflow: hidden;
} }
#previewer .action i { #previewer .action i {
@ -148,6 +153,40 @@
height: calc(100vh - 9.7em); height: calc(100vh - 9.7em);
} }
#previewer .editor {
max-height: 100%;
overflow: auto;
}
#previewer .text {
height: 100%;
padding: 16px;
overflow: auto;
background: white;
text-align: left;
white-space: pre-wrap;
}
#previewer .markdown {
height: 100%;
padding: 16px;
overflow: auto;
background: white;
text-align: left;
}
#previewer .html {
height: 100%;
background: white;
text-align: left;
white-space: pre;
}
#previewer .html iframe {
width: 100%;
height: 100%;
}
#previewer .preview pre { #previewer .preview pre {
text-align: left; text-align: left;
overflow: auto; overflow: auto;

View File

@ -10,6 +10,7 @@
"create": "Create", "create": "Create",
"delete": "Delete", "delete": "Delete",
"download": "Download", "download": "Download",
"edit": "Edit",
"info": "Info", "info": "Info",
"more": "More", "more": "More",
"move": "Move", "move": "Move",

View File

@ -2,7 +2,10 @@ const getters = {
isLogged: state => state.user !== null, isLogged: state => state.user !== null,
isFiles: state => !state.loading && state.route.name === 'Files', isFiles: state => !state.loading && state.route.name === 'Files',
isListing: (state, getters) => getters.isFiles && state.req.isDir, isListing: (state, getters) => getters.isFiles && state.req.isDir,
isEditor: (state, getters) => getters.isFiles && (state.req.type === 'text' || state.req.type === 'textImmutable'), isPreview: (state, getters) => !state.loading && !getters.isListing && !getters.isEditor,
isEditor: (state, getters) => getters.isFiles && state.showEditor,
isFileEditable: (state) => state.req.type === 'text' || state.req.type === 'textImmutable',
getPreviewContent: state => state.previewContent,
selectedCount: state => state.selected.length selectedCount: state => state.selected.length
} }

View File

@ -22,7 +22,9 @@ const state = {
show: null, show: null,
showShell: false, showShell: false,
showMessage: null, showMessage: null,
showConfirm: null showConfirm: null,
showEditor: false,
previewContent: null
} }
export default new Vuex.Store({ export default new Vuex.Store({

View File

@ -85,6 +85,12 @@ const mutations = {
}, },
setProgress: (state, value) => { setProgress: (state, value) => {
state.progress = value state.progress = value
},
toggleEditor: (state) => (
state.showEditor = !state.showEditor
),
setPreviewContent: (state, value) => {
state.previewContent = value
} }
} }

View File

@ -10,7 +10,6 @@ const logoURL = `/${staticURL}/img/logo.svg`
const noAuth = window.FileBrowser.NoAuth const noAuth = window.FileBrowser.NoAuth
const authMethod = window.FileBrowser.AuthMethod const authMethod = window.FileBrowser.AuthMethod
const loginPage = window.FileBrowser.LoginPage const loginPage = window.FileBrowser.LoginPage
const theme = window.FileBrowser.Theme
export { export {
name, name,
@ -23,6 +22,5 @@ export {
version, version,
noAuth, noAuth,
authMethod, authMethod,
loginPage, loginPage
theme
} }

View File

@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<div id="breadcrumbs"> <div v-if="isListing" id="breadcrumbs">
<router-link to="/files/" :aria-label="$t('files.home')" :title="$t('files.home')"> <router-link to="/files/" :aria-label="$t('files.home')" :title="$t('files.home')">
<i class="material-icons">home</i> <i class="material-icons">home</i>
</router-link> </router-link>
@ -53,6 +53,7 @@ export default {
...mapGetters([ ...mapGetters([
'selectedCount', 'selectedCount',
'isListing', 'isListing',
'isPreview',
'isEditor', 'isEditor',
'isFiles' 'isFiles'
]), ]),
@ -63,9 +64,6 @@ export default {
'multiple', 'multiple',
'loading' 'loading'
]), ]),
isPreview () {
return !this.loading && !this.isListing && !this.isEditor
},
breadcrumbs () { breadcrumbs () {
let parts = this.$route.path.split('/') let parts = this.$route.path.split('/')
@ -151,6 +149,9 @@ export default {
this.$store.commit('updateRequest', res) this.$store.commit('updateRequest', res)
document.title = res.name document.title = res.name
if (res.content)
this.$store.commit('setPreviewContent', res.content)
} catch (e) { } catch (e) {
this.error = e this.error = e
} finally { } finally {