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",
"js-base64": "^2.5.1",
"lodash.clonedeep": "^4.5.0",
"marked": "^0.8.0",
"material-design-icons": "^3.0.1",
"moment": "^2.24.0",
"normalize.css": "^8.0.1",

View File

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

View File

@ -8,7 +8,7 @@
<search v-if="isLogged"></search>
</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">
<i class="material-icons">search</i>
</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>
<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>
</button>
</template>

View File

@ -1,5 +1,18 @@
<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>
<script>
@ -10,55 +23,50 @@ import buttons from '@/utils/buttons'
import ace from 'ace-builds/src-min-noconflict/ace.js'
import modelist from 'ace-builds/src-min-noconflict/ext-modelist.js'
import 'ace-builds/webpack-resolver'
import { theme } from '@/utils/constants'
export default {
name: 'editor',
computed: {
...mapState(['req'])
...mapState(['req', 'user', 'previewContent'])
},
data: function () {
return {}
return {
editor: null,
contentChanged: false
}
},
created () {
window.addEventListener('keydown', this.keyEvent)
document.getElementById('save-button').addEventListener('click', this.save)
},
beforeDestroy () {
window.removeEventListener('keydown', this.keyEvent)
document.getElementById('save-button').removeEventListener('click', this.save)
this.editor.destroy();
},
mounted: function () {
const fileContent = this.req.content || '';
this.editor = ace.edit('editor', {
maxLines: Infinity,
minLines: 20,
value: fileContent,
value: this.previewContent,
showPrintMargin: false,
readOnly: this.req.type === 'textImmutable',
theme: 'ace/theme/chrome',
theme: 'ace/theme/twilight',
mode: modelist.getModeForPath(this.req.name).mode,
wrap: true
})
if (theme == 'dark') {
this.editor.setTheme("ace/theme/twilight");
}
this.editor.on('change', () => {
this.contentChanged = true
})
},
methods: {
keyEvent (event) {
if (!event.ctrlKey && !event.metaKey) {
return
}
let key = String.fromCharCode(event.which).toLowerCase()
if (String.fromCharCode(event.which).toLowerCase() !== 's') {
return
if ((event.ctrlKey || event.metaKey) && key == 's') {
event.preventDefault()
this.save()
} else if (event.which === 27) { // Esc
this.$store.commit('toggleEditor')
}
event.preventDefault()
this.save()
},
async save () {
const button = 'save'
@ -66,11 +74,18 @@ export default {
try {
await api.put(this.$route.path, this.editor.getValue())
this.$store.commit('setPreviewContent', this.editor.getValue())
this.contentChanged = false
buttons.success(button)
} catch (e) {
buttons.done(button)
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">
<i class="material-icons">close</i>
</button>
<span class="title">{{ req.name }}</span>
<edit-button v-if="isFileEditable"></edit-button>
<rename-button v-if="user.perm.rename"></rename-button>
<delete-button v-if="user.perm.delete"></delete-button>
<download-button v-if="user.perm.download"></download-button>
@ -33,29 +34,37 @@
and watch it with your favorite video player!
</video>
<object v-else-if="req.extension == '.pdf'" class="pdf" :data="raw"></object>
<a v-else-if="req.type == 'blob'" :href="download">
<h2 class="message">{{ $t('buttons.download') }} <i class="material-icons">file_download</i></h2>
</a>
<div class="html" v-else-if="req.extension == '.html'"> <iframe :src="getHtmlContent()"></iframe> </div>
<div class="markdown" v-else-if="isMarkdown" v-html="getMarkdownContent()"></div>
<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>
</template>
<script>
import { mapState } from 'vuex'
import { mapGetters, mapState } from 'vuex'
import url from '@/utils/url'
import { baseURL } from '@/utils/constants'
import { files as api } from '@/api'
import marked from 'marked'
import InfoButton from '@/components/buttons/Info'
import DeleteButton from '@/components/buttons/Delete'
import RenameButton from '@/components/buttons/Rename'
import DownloadButton from '@/components/buttons/Download'
import EditButton from '@/components/buttons/Edit'
import ExtendedImage from './ExtendedImage'
const mediaTypes = [
"image",
"video",
"audio",
"blob"
const markdownExtesions = [
'.md',
'.mkdn',
'.mdwn',
'.mdown',
'.markdown'
]
export default {
@ -65,6 +74,7 @@ export default {
DeleteButton,
RenameButton,
DownloadButton,
EditButton,
ExtendedImage
},
data: function () {
@ -76,7 +86,8 @@ export default {
}
},
computed: {
...mapState(['req', 'user', 'oldReq', 'jwt']),
...mapGetters(['isFileEditable']),
...mapState(['req', 'user', 'oldReq', 'jwt', 'previewContent']),
hasPrevious () {
return (this.previousLink !== '')
},
@ -88,10 +99,13 @@ export default {
},
raw () {
return `${this.download}&inline=true`
},
isMarkdown () {
return markdownExtesions.includes(this.req.extension)
}
},
async mounted () {
window.addEventListener('keyup', this.key)
window.addEventListener('keydown', this.key)
if (this.req.subtitles) {
this.subtitles = this.req.subtitles.map(sub => `${baseURL}/api/raw${sub}?auth=${this.jwt}&inline=true`)
@ -110,7 +124,7 @@ export default {
}
},
beforeDestroy () {
window.removeEventListener('keyup', this.key)
window.removeEventListener('keydown', this.key)
},
methods: {
back () {
@ -130,6 +144,8 @@ export default {
if (this.hasNext) this.next()
} else if (event.which === 37) { // left arrow
if (this.hasPrevious) this.prev()
} else if (event.which == 27) { // esc
this.back()
}
},
updateLinks (items) {
@ -139,14 +155,14 @@ export default {
}
for (let j = i - 1; j >= 0; j--) {
if (mediaTypes.includes(items[j].type)) {
if (!items[j].isDir) {
this.previousLink = items[j].url
break
}
}
for (let j = i + 1; j < items.length; j++) {
if (mediaTypes.includes(items[j].type)) {
if (!items[j].isDir) {
this.nextLink = items[j].url
break
}
@ -154,6 +170,12 @@ export default {
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;
}
#previewer .action:first-of-type {
#previewer .title {
color: #fff;
font-size: 18px;
line-height: 2.4;
padding: 0 8px;
margin-right: auto;
overflow: hidden;
}
#previewer .action i {
@ -148,6 +153,40 @@
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 {
text-align: left;
overflow: auto;

View File

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

View File

@ -2,7 +2,10 @@ const getters = {
isLogged: state => state.user !== null,
isFiles: state => !state.loading && state.route.name === 'Files',
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
}

View File

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

View File

@ -85,6 +85,12 @@ const mutations = {
},
setProgress: (state, 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 authMethod = window.FileBrowser.AuthMethod
const loginPage = window.FileBrowser.LoginPage
const theme = window.FileBrowser.Theme
export {
name,
@ -23,6 +22,5 @@ export {
version,
noAuth,
authMethod,
loginPage,
theme
loginPage
}

View File

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