Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(viewer): add Text, Markdown and Source Code viewer (read-only) #785

Merged
merged 1 commit into from
Sep 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions src/talk/renderer/Viewer/Viewer.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export async function createViewer() {
const { default: ViewerHandlerImages } = await import('./ViewerHandlerImages.vue')
const { default: ViewerHandlerVideos } = await import('./ViewerHandlerVideos.vue')
const { default: ViewerHandlerPdf } = await import('./ViewerHandlerPdf.vue')
const { default: ViewerHandlerText } = await import('./ViewerHandlerText.vue')

const Viewer = {
availableHandlers: [{
Expand Down Expand Up @@ -50,6 +51,38 @@ export async function createViewer() {
group: 'document',
mimes: ['application/pdf'],
component: ViewerHandlerPdf,
}, {
id: 'text',
group: 'document',
mimes: [
'text/markdown',
'text/plain',
],
component: ViewerHandlerText,
}, {
id: 'text',
group: 'code',
mimes: [
'application/javascript', // .js .mjs .cjs
'application/json', // .json
'application/x-msdos-program', // .bat .cmd
'application/x-perl', // .pl
'application/x-php', // .php
'application/xml', // .xml
'application/yaml', // .yaml .yml
'text/css', // .css
'text/csv', // .csv
'text/html', // .html
'text/x-c', // .c
'text/x-c++src', // .cpp
'text/x-h', // .h
'text/x-java-source', // .java
'text/x-ldif', // .ldif
'text/x-python', // .py
'text/x-rst', // .rst
'text/x-shellscript', // .sh
],
Antreesy marked this conversation as resolved.
Show resolved Hide resolved
component: ViewerHandlerText,
}],

open(...args) {
Expand Down
220 changes: 220 additions & 0 deletions src/talk/renderer/Viewer/ViewerHandlerText.vue
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't know where it belongs code-wise, but I faced the caching issue:

  • File is changed on server;
  • Client is still keeping the old version (was empty for me);

Maybe could be done in a follow-up, but how can it better be done? Sync button, null cache time?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, a text file has no ETag change after its content was changed... So technically the cached value is valid...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checking with the office team

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Works fine if reopen after some short time, just not immediately after is was changes in the web-editor.

Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
<!--
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->

<script setup>
import { computed, ref } from 'vue'
import { t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
import NcRichText from '@nextcloud/vue/dist/Components/NcRichText.js'
import IconContentCopy from 'vue-material-design-icons/ContentCopy.vue'
import IconCheck from 'vue-material-design-icons/Check.vue'
import IconFileChartOutline from 'vue-material-design-icons/FileChartOutline.vue'
import IconFileDocumentOutline from 'vue-material-design-icons/FileDocumentOutline.vue'
import IconFileOutline from 'vue-material-design-icons/FileOutline.vue'
import IconWrap from 'vue-material-design-icons/Wrap.vue'
import ViewerHandlerBase from './ViewerHandlerBase.vue'
import { useFileContent } from './viewer.composables.ts'
import { toRef } from '@vueuse/core'

const props = defineProps({
file: {
type: Object,
required: true,
},
})

const format = computed(() => {
const mimeToFormat = {
'text/markdown': 'md',
'text/plain': 'txt',
}
return mimeToFormat[props.file.mime] ?? 'code'
})

const layout = ref('compact')

const wrap = ref(true)
const wrapLabel = computed(() => format.value === 'md' ? t('talk_desktop', 'Wrap content in code blocks') : t('talk_desktop', 'Wrap content'))

const justCopied = ref(false)

/**
* Copy the content of the file to the clipboard
*/
function copy() {
navigator.clipboard.writeText(content.value)
justCopied.value = true
setTimeout(() => {
justCopied.value = false
}, 2000)
}

const { content, loading, error } = useFileContent(toRef(() => props.file.filename), 'text')
</script>

<template>
<ViewerHandlerBase :loading="loading" :error="error" :error-description="error">
<template #default>
<div class="viewer-text">
<template v-if="content">
<fieldset :aria-label="t('talk_desktop', 'Controls')" class="viewer-text__controls">
<fieldset class="viewer-text__layout-switch" :aria-label="t('talk_desktop', 'Layout')">
<NcCheckboxRadioSwitch :checked.sync="layout"
:aria-label="t('talk_desktop', 'Compact')"
value="compact"
type="radio"
name="layout"
button-variant
button-variant-grouped="horizontal">
<template #icon>
<IconFileDocumentOutline :size="20" />
</template>
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch :checked.sync="layout"
:aria-label="t('talk_desktop', 'Wide')"
value="wide"
type="radio"
name="layout"
button-variant
button-variant-grouped="horizontal">
<template #icon>
<IconFileChartOutline :size="20" style="transform: rotate(90deg) scaleX(-1)" />
</template>
</NcCheckboxRadioSwitch>
</fieldset>
<NcButton :aria-label="wrapLabel"
:pressed.sync="wrap"
:title="wrapLabel"
type="tertiary">
<template #icon>
<IconWrap :size="20" />
</template>
</NcButton>
<NcButton :aria-label="t('talk_desktop', 'Copy content')"
:title="t('talk_desktop', 'Copy content')"
type="tertiary"
@click="copy">
<template #icon>
<IconCheck v-if="justCopied" :size="20" />
<IconContentCopy v-else :size="20" />
</template>
</NcButton>
</fieldset>

<div :aria-label="t('talk_desktop', 'Read-only text file content')"
class="viewer-text__content"
:class="[
`viewer-text__content--${format}`, {
'viewer-text__content--compact': layout === 'compact',
'viewer-text__content--wrap': wrap,
}]"
contenteditable
spellcheck="false"
@beforeinput.prevent>
Antreesy marked this conversation as resolved.
Show resolved Hide resolved
<code v-if="format === 'code'">{{ content }}</code>
<NcRichText v-else :text="content" :use-extended-markdown="format === 'md'" />
</div>
</template>

<div v-else class="viewer-text__empty">
<NcEmptyContent :name="t('talk_desktop', 'The file is empty')">
<template #icon>
<IconFileOutline />
</template>
</NcEmptyContent>
</div>
</div>
</template>
</ViewerHandlerBase>
</template>

<style scoped>
.viewer-text {
background-color: var(--color-main-background);
position: relative;
overflow: auto;
display: flex;
flex-direction: column;
align-items: center;
padding: 0 calc(var(--default-grid-baseline) * 2);
}

.viewer-text__controls {
background-color: inherit;
width: 100%;
display: flex;
gap: var(--default-grid-baseline);
justify-content: flex-end;
padding: calc(var(--default-grid-baseline) * 2) 0;
position: sticky;
inset-block-start: 0;
}

.viewer-text__layout-switch {
display: flex;
}

.viewer-text__content {
flex: 1 0 auto;
width: 100%;
overflow-x: auto;
/* Reset global rich contenteditable styles */
background-color: unset;
border: none;
border-radius: 0;
padding: 0;
margin: 0;

&:focus-visible,
&:hover,
&:focus,
&:active {
box-shadow: none !important;
border: none !important;
}
}

.viewer-text__content--compact {
max-width: 900px;
}

.viewer-text__content--md :deep(pre) {
Antreesy marked this conversation as resolved.
Show resolved Hide resolved
background: var(--color-background-dark);
padding: 1em;
overflow: auto;
white-space: pre;
}

.viewer-text__content--md :deep(ul) {
/* The default value is too small and doesnt fit the marker */
/* TODO: fix in upstream */
padding-left: 1.2em !important;
}

.viewer-text__content--md.viewer-text__content--wrap :deep(pre) {
white-space: pre-wrap;
}

.viewer-text__content--txt,
.viewer-text__content--code {
white-space: pre;

&.viewer-text__content--wrap {
white-space: pre-wrap;
}
}

.viewer-text__content--code code {
display: block;
}

.viewer-text__empty {
height: 100%;
display: flex;
flex-direction: column;
}
</style>