Skip to content
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
29 changes: 23 additions & 6 deletions packages/design-system/src/components/OcTextInput/OcTextInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,11 @@
</template>

<script setup lang="ts">
import { computed, nextTick, useAttrs, useTemplateRef, unref } from 'vue'
import { uniqueId } from '../../helpers'
import { computed, nextTick, unref, useAttrs, useTemplateRef, watch } from 'vue'
import { PasswordPolicy, uniqueId } from '../../helpers'
import OcButton from '../OcButton/OcButton.vue'
import OcIcon from '../OcIcon/OcIcon.vue'
import OcTextInputPassword from '../OcTextInputPassword/OcTextInputPassword.vue'
import { PasswordPolicy } from '../../helpers'
import { PortalTarget } from 'portal-vue'

defineOptions({
Expand Down Expand Up @@ -167,18 +166,22 @@ export interface Emits {
* @docs Emitted when the value of the input has changed after the user confirms or leaves the focus.
*/
(e: 'change', value: string): void

/**
* @docs Emitted when the value of the input has updated.
*/
(e: 'update:modelValue', value: string): void

/**
* @docs Emitted when the input has been focused.
*/
(e: 'focus', value: string): void

/**
* @docs Emitted when the password challenge has been completed successfully.
*/
(e: 'passwordChallengeCompleted'): void

/**
* @docs Emitted when the password challenge has failed.
*/
Expand Down Expand Up @@ -291,12 +294,26 @@ const onInput = (value: string) => {

const onFocus = async (target: HTMLInputElement) => {
await nextTick()
target.select()
unref(inputRef).select()
setSelectionRange()
emit('focus', target.value)
}
const setSelectionRange = () => {
if (selectionRange && selectionRange.length > 1) {
target.setSelectionRange(selectionRange[0], selectionRange[1])
unref(inputRef).setSelectionRange(selectionRange[0], selectionRange[1])
}
emit('focus', target.value)
}
watch(
[() => selectionRange, inputRef],
async () => {
if (!unref(inputRef)) {
return
}
await nextTick()
setSelectionRange()
},
{ immediate: true }
)
</script>

<style lang="scss">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,15 @@ const focus = () => {
unref(passwordInput).focus()
}

defineExpose({ focus })
const select = () => {
unref(passwordInput).select()
}

const setSelectionRange = (start: number, end: number) => {
unref(passwordInput).setSelectionRange(start, end)
}

defineExpose({ focus, select, setSelectionRange })

watch(password, (value) => {
if (!Object.keys(passwordPolicy).length) {
Expand Down
135 changes: 131 additions & 4 deletions packages/web-app-external/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
</div>
</form>
<iframe
ref="appIframe"
name="app-iframe"
class="oc-width-1-1 oc-height-1-1"
:title="iFrameTitle"
Expand All @@ -27,7 +28,16 @@

<script setup lang="ts">
import { stringify } from 'qs'
import { computed, unref, nextTick, ref, watch, onMounted, useTemplateRef } from 'vue'
import {
computed,
unref,
nextTick,
ref,
watch,
onMounted,
useTemplateRef,
onBeforeUnmount
} from 'vue'
import { useTask } from 'vue-concurrency'
import { useGettext } from 'vue3-gettext'
import {
Expand All @@ -53,8 +63,11 @@ import {
setCurrentUserShareSpacePermissions,
useSpacesStore,
useClientService,
useSharesStore
useSharesStore,
useModals,
useRouter
} from '@opencloud-eu/web-pkg'
import FileNameModal from './components/FileNameModal.vue'

const { space, resource, isReadOnly } = defineProps<{
space: SpaceResource
Expand All @@ -70,11 +83,14 @@ const { showErrorMessage } = useMessages()
const capabilityStore = useCapabilityStore()
const configStore = useConfigStore()
const route = useRoute()
const router = useRouter()
const appProviderService = useAppProviderService()
const { makeRequest } = useRequest()
const spacesStore = useSpacesStore()
const sharesStore = useSharesStore()
const { graphAuthenticated: graphClient } = useClientService()
const { dispatchModal } = useModals()
const { webdav } = useClientService()

const viewModeQuery = useRouteQuery('view_mode')
const viewModeQueryValue = computed(() => {
Expand Down Expand Up @@ -188,6 +204,8 @@ const determineOpenAsPreview = (appName: string) => {
return openAsPreview === true || (Array.isArray(openAsPreview) && openAsPreview.includes(appName))
}

const isCollabora = unref(appName)?.toLowerCase()?.startsWith('collabora')

// switch to write mode when edit is clicked
const catchClickMicrosoftEdit = (event: MessageEvent) => {
try {
Expand All @@ -196,17 +214,126 @@ const catchClickMicrosoftEdit = (event: MessageEvent) => {
}
} catch {}
}

const handlePostMessagesCollabora = async (event: MessageEvent) => {
try {
const message = JSON.parse(event.data || '{}')

if (message.MessageId === 'App_LoadingStatus' && message.Values?.Status === 'Frame_Ready') {
postMessageToCollabora('Host_PostmessageReady')
return
}

if (message.MessageId === 'UI_SaveAs') {
if (Object.hasOwn(message.Values, 'format')) {
dispatchModal({
title: $gettext('Export %{name} as %{format}', {
name: resource.name,
format: message.Values.format
}),
customComponent: FileNameModal,
customComponentAttrs: () => ({
space,
resource,
fileExtension: message.Values.format,
callbackFn: (newFileName: string) => {
postMessageToCollabora('Action_SaveAs', {
Filename: newFileName,
Notify: true
})
}
})
})
return
}

dispatchModal({
title: $gettext('Save %{name} with new name', { name: resource.name }),
customComponent: FileNameModal,
customComponentAttrs: () => ({
space,
resource,
callbackFn: (newFileName: string) => {
postMessageToCollabora('Action_SaveAs', {
Filename: newFileName,
Notify: true
})
}
})
})
return
}

if (message.MessageId === 'Action_Save_Resp') {
if (!message.Values?.fileName) {
return
}

// FIXME: when we move to id based propfinds we magically need a fileId for the new file. Collabora doesn't provide that.
const newFile = await webdav.getFileInfo(space, {
path:
resource.path.substring(0, resource.path.length - resource.name.length) +
message.Values.fileName,
fileId: undefined
})
await router.push({
name: unref(route).name,
params: {
...unref(route).params,
driveAliasAndItem: queryItemAsString(unref(route).params.driveAliasAndItem).replace(
resource.name,
newFile.name
)
},
query: {
...unref(route).query,
fileId: newFile.fileId
}
})
return
}
} catch (e) {
console.debug('Error parsing Collabora PostMessage', e)
}
}

onMounted(() => {
if (determineOpenAsPreview(unref(appName))) {
window.addEventListener('message', catchClickMicrosoftEdit)
} else {
window.removeEventListener('message', catchClickMicrosoftEdit)
}

if (isCollabora) {
window.addEventListener('message', handlePostMessagesCollabora)
}
})
onBeforeUnmount(() => {
window.removeEventListener('message', catchClickMicrosoftEdit)
if (isCollabora) {
window.removeEventListener('message', handlePostMessagesCollabora)
}
})

const appIframeRef = useTemplateRef<HTMLIFrameElement>('appIframe')
const postMessageToCollabora = (messageId: string, values?: { [key: string]: unknown }): void => {
if (!unref(appIframeRef)) {
console.error('Collabora iframe not found')
return
}
return unref(appIframeRef).contentWindow.postMessage(
JSON.stringify({
MessageId: messageId,
SendTime: Date.now(),
...(values && { Values: values })
}),
'*'
)
}

watch(
[resource],
async ([newResource], [oldResource]) => {
() => resource,
async (newResource, oldResource) => {
if (isSameResource(newResource, oldResource)) {
return
}
Expand Down
92 changes: 92 additions & 0 deletions packages/web-app-external/src/components/FileNameModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<template>
<form autocomplete="off" @submit.prevent="onConfirm">
<oc-text-input
id="file-name-input"
v-model="newFileName"
class="oc-mb-s"
:label="$gettext('File name')"
required-mark
:error-message="errorMessage"
:fix-message-line="true"
:selection-range="inputSelectionRange"
@keydown.enter.prevent="emit('confirm')"
/>
<input type="submit" class="oc-hidden" />
</form>
</template>

<script setup lang="ts">
import { extractNameWithoutExtension, Resource, SpaceResource } from '@opencloud-eu/web-client'
import {
Modal,
resolveFileNameDuplicate,
useClientService,
useIsResourceNameValid
} from '@opencloud-eu/web-pkg'
import { computed, ref, unref } from 'vue'
import { DavProperty } from '@opencloud-eu/web-client/webdav'
import { useTask } from 'vue-concurrency'

const { webdav } = useClientService()
const { isFileNameValid } = useIsResourceNameValid()

const {
space,
resource,
fileExtension = undefined,
callbackFn
} = defineProps<{
space: SpaceResource
resource: Resource
fileExtension?: string
fileExtensionsShown?: boolean
modal: Modal
callbackFn: (newFileName: string) => Promise<void>
}>()
const emit = defineEmits(['confirm'])

const newFileName = ref('')
const parentResources = ref<Resource[]>([])
const inputSelectionRange = ref<[number, number]>([0, resource?.name?.length || 0])

const buildFileNameTask = useTask(function* () {
const { children: existingFiles } = yield webdav.listFiles(
space,
{ fileId: resource.parentFolderId },
{ davProperties: [DavProperty.Name] }
)
parentResources.value = existingFiles

const fileName = fileExtension
? `${extractNameWithoutExtension(resource)}.${fileExtension}`
: resource.name
const hasConflict = existingFiles.some((f) => f.name === fileName)
newFileName.value = hasConflict
? resolveFileNameDuplicate(fileName, fileExtension || resource.extension, existingFiles)
: fileName

inputSelectionRange.value = [
0,
extractNameWithoutExtension({
name: unref(newFileName),
extension: fileExtension || resource.extension
} as Resource).length
]
})
buildFileNameTask.perform()

const errorMessage = computed(() => {
const { isValid, error } = isFileNameValid(resource, unref(newFileName), unref(parentResources))
if (!isValid) {
return error
}
return undefined
})

const onConfirm = async () => {
await callbackFn(unref(newFileName))
}
defineExpose({
onConfirm
})
</script>
Loading