UBERF-4946 Drive files preview (#5638)

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>
This commit is contained in:
Alexander Onnikov 2024-05-22 22:14:24 +07:00 committed by GitHub
parent 866e82e89b
commit 762483d7d5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 979 additions and 540 deletions

View File

@ -14,14 +14,7 @@
// //
import activity from '@hcengineering/activity' import activity from '@hcengineering/activity'
import type { import type { Attachment, AttachmentMetadata, Photo, SavedAttachments } from '@hcengineering/attachment'
Attachment,
AttachmentMetadata,
Photo,
SavedAttachments,
AttachmentPreviewExtension
} from '@hcengineering/attachment'
import presentation, { TComponentPointExtension } from '@hcengineering/model-presentation'
import { type Domain, IndexKind, type Ref } from '@hcengineering/core' import { type Domain, IndexKind, type Ref } from '@hcengineering/core'
import { import {
type Builder, type Builder,
@ -38,7 +31,6 @@ import {
import core, { TAttachedDoc } from '@hcengineering/model-core' import core, { TAttachedDoc } from '@hcengineering/model-core'
import preference, { TPreference } from '@hcengineering/model-preference' import preference, { TPreference } from '@hcengineering/model-preference'
import view, { createAction } from '@hcengineering/model-view' import view, { createAction } from '@hcengineering/model-view'
import { type Resource } from '@hcengineering/platform'
import attachment from './plugin' import attachment from './plugin'
@ -86,17 +78,8 @@ export class TSavedAttachments extends TPreference implements SavedAttachments {
declare attachedTo: Ref<Attachment> declare attachedTo: Ref<Attachment>
} }
@Model(attachment.class.AttachmentPreviewExtension, presentation.class.ComponentPointExtension)
export class TAttachmentPreviewExtension extends TComponentPointExtension implements AttachmentPreviewExtension {
@Prop(TypeString(), attachment.string.ContentType)
contentType!: string | string[]
alignment?: string
availabilityChecker?: Resource<() => Promise<boolean>>
}
export function createModel (builder: Builder): void { export function createModel (builder: Builder): void {
builder.createModel(TAttachment, TPhoto, TSavedAttachments, TAttachmentPreviewExtension) builder.createModel(TAttachment, TPhoto, TSavedAttachments)
builder.mixin(attachment.class.Attachment, core.class.Class, view.mixin.ObjectPresenter, { builder.mixin(attachment.class.Attachment, core.class.Class, view.mixin.ObjectPresenter, {
presenter: attachment.component.AttachmentPresenter presenter: attachment.component.AttachmentPresenter
@ -181,42 +164,6 @@ export function createModel (builder: Builder): void {
attachment.category.Attachments attachment.category.Attachments
) )
builder.createDoc(
attachment.class.AttachmentPreviewExtension,
core.space.Model,
{
contentType: 'image/*',
alignment: 'centered',
component: attachment.component.PDFViewer,
extension: attachment.extension.AttachmentPreview
},
attachment.previewExtension.Image
)
builder.createDoc(
attachment.class.AttachmentPreviewExtension,
core.space.Model,
{
contentType: ['video/*', 'audio/*'],
alignment: 'centered',
component: attachment.component.MediaViewer,
extension: attachment.extension.AttachmentPreview
},
attachment.previewExtension.Media
)
builder.createDoc(
attachment.class.AttachmentPreviewExtension,
core.space.Model,
{
contentType: ['application/pdf', 'application/json', 'text/*'],
alignment: 'float',
component: attachment.component.PDFViewer,
extension: attachment.extension.AttachmentPreview
},
attachment.previewExtension.PDF
)
createAction(builder, { createAction(builder, {
action: view.actionImpl.ShowEditor, action: view.actionImpl.ShowEditor,
actionProps: { actionProps: {

View File

@ -14,19 +14,31 @@
// //
import core, { import core, {
type Blob,
type Domain, type Domain,
type Role, type Role,
type RolesAssignment, type RolesAssignment,
type Type, type Type,
Account, Account,
AccountRole, AccountRole,
Ref,
IndexKind, IndexKind,
DOMAIN_MODEL, Ref,
type Blob DOMAIN_MODEL
} from '@hcengineering/core' } from '@hcengineering/core'
import { type Drive, type File, type Folder, type Resource, driveId } from '@hcengineering/drive' import { type Drive, type File, type Folder, type Resource, driveId } from '@hcengineering/drive'
import { type Builder, Model, UX, Mixin, Prop, TypeString, Index, TypeRef, ReadOnly } from '@hcengineering/model' import {
type Builder,
Hidden,
Index,
Mixin,
Model,
Prop,
ReadOnly,
TypeRecord,
TypeRef,
TypeString,
UX
} from '@hcengineering/model'
import { TDoc, TType, TTypedSpace } from '@hcengineering/model-core' import { TDoc, TType, TTypedSpace } from '@hcengineering/model-core'
import tracker from '@hcengineering/model-tracker' import tracker from '@hcengineering/model-tracker'
import view, { type Viewlet, classPresenter, createAction } from '@hcengineering/model-view' import view, { type Viewlet, classPresenter, createAction } from '@hcengineering/model-view'
@ -103,6 +115,11 @@ export class TFile extends TResource implements File {
@ReadOnly() @ReadOnly()
declare file: Ref<Blob> declare file: Ref<Blob>
@Prop(TypeRecord(), drive.string.Metadata)
@ReadOnly()
@Hidden()
metadata?: Record<string, any>
@Prop(TypeRef(drive.class.Folder), drive.string.Parent) @Prop(TypeRef(drive.class.Folder), drive.string.Parent)
@Index(IndexKind.Indexed) @Index(IndexKind.Indexed)
@ReadOnly() @ReadOnly()

View File

@ -72,6 +72,7 @@ export default mergeIds(driveId, drive, {
Description: '' as IntlString, Description: '' as IntlString,
Size: '' as IntlString, Size: '' as IntlString,
Type: '' as IntlString, Type: '' as IntlString,
Metadata: '' as IntlString,
LastModified: '' as IntlString, LastModified: '' as IntlString,
Parent: '' as IntlString, Parent: '' as IntlString,
Path: '' as IntlString, Path: '' as IntlString,

View File

@ -13,8 +13,8 @@
// limitations under the License. // limitations under the License.
// //
import { type Class, DOMAIN_MODEL, type Doc, type Ref } from '@hcengineering/core' import { type Blob, type Class, type Doc, type Ref, DOMAIN_MODEL } from '@hcengineering/core'
import { type Builder, Model, Prop, TypeRef } from '@hcengineering/model' import { type Builder, Model, Prop, TypeRef, TypeString } from '@hcengineering/model'
import core, { TDoc } from '@hcengineering/model-core' import core, { TDoc } from '@hcengineering/model-core'
import { type Asset, type IntlString, type Resource } from '@hcengineering/platform' import { type Asset, type IntlString, type Resource } from '@hcengineering/platform'
// Import types to prevent .svelte components to being exposed to type typescript. // Import types to prevent .svelte components to being exposed to type typescript.
@ -23,12 +23,14 @@ import {
type PresentationMiddlewareFactory type PresentationMiddlewareFactory
} from '@hcengineering/presentation/src/pipeline' } from '@hcengineering/presentation/src/pipeline'
import { import {
type BlobMetadata,
type ComponentPointExtension, type ComponentPointExtension,
type CreateExtensionKind, type CreateExtensionKind,
type DocAttributeRule, type DocAttributeRule,
type DocRules, type DocRules,
type DocCreateExtension, type DocCreateExtension,
type DocCreateFunction, type DocCreateFunction,
type FilePreviewExtension,
type ObjectSearchContext, type ObjectSearchContext,
type ObjectSearchCategory, type ObjectSearchCategory,
type ObjectSearchFactory type ObjectSearchFactory
@ -82,12 +84,23 @@ export class TDocRules extends TDoc implements DocRules {
fieldRules!: DocAttributeRule[] fieldRules!: DocAttributeRule[]
} }
@Model(presentation.class.FilePreviewExtension, presentation.class.ComponentPointExtension)
export class TFilePreviewExtension extends TComponentPointExtension implements FilePreviewExtension {
@Prop(TypeString(), presentation.string.ContentType)
contentType!: string | string[]
alignment?: string
metadataProvider?: Resource<(file: File, blob: Ref<Blob>) => Promise<BlobMetadata | undefined>>
availabilityChecker?: Resource<() => Promise<boolean>>
}
export function createModel (builder: Builder): void { export function createModel (builder: Builder): void {
builder.createModel( builder.createModel(
TObjectSearchCategory, TObjectSearchCategory,
TPresentationMiddlewareFactory, TPresentationMiddlewareFactory,
TComponentPointExtension, TComponentPointExtension,
TDocCreateExtension, TDocCreateExtension,
TDocRules TDocRules,
TFilePreviewExtension
) )
} }

View File

@ -598,6 +598,56 @@ export function createModel (builder: Builder): void {
view.pipeline.AnalyticsMiddleware view.pipeline.AnalyticsMiddleware
) )
builder.createDoc(
presentation.class.FilePreviewExtension,
core.space.Model,
{
contentType: ['audio/*'],
alignment: 'centered',
component: view.component.AudioViewer,
extension: presentation.extension.FilePreviewExtension
},
view.extension.Audio
)
builder.createDoc(
presentation.class.FilePreviewExtension,
core.space.Model,
{
contentType: 'image/*',
alignment: 'centered',
component: view.component.ImageViewer,
metadataProvider: view.function.BlobImageMetadata,
extension: presentation.extension.FilePreviewExtension
},
view.extension.Image
)
builder.createDoc(
presentation.class.FilePreviewExtension,
core.space.Model,
{
contentType: ['video/*'],
alignment: 'centered',
component: view.component.VideoViewer,
metadataProvider: view.function.BlobVideoMetadata,
extension: presentation.extension.FilePreviewExtension
},
view.extension.Video
)
builder.createDoc(
presentation.class.FilePreviewExtension,
core.space.Model,
{
contentType: ['application/pdf', 'application/json', 'text/*'],
alignment: 'float',
component: view.component.PDFViewer,
extension: presentation.extension.FilePreviewExtension
},
view.extension.PDF
)
createAction( createAction(
builder, builder,
{ {

View File

@ -13,10 +13,11 @@
// limitations under the License. // limitations under the License.
// //
import { type Doc, type Ref } from '@hcengineering/core' import { type Blob, type Doc, type Ref } from '@hcengineering/core'
import { type IntlString, mergeIds, type Resource } from '@hcengineering/platform' import { type IntlString, mergeIds, type Resource } from '@hcengineering/platform'
import { type AnyComponent } from '@hcengineering/ui/src/types' import { type AnyComponent } from '@hcengineering/ui/src/types'
import { type FilterFunction, type ViewAction, type ViewCategoryAction, viewId } from '@hcengineering/view' import { type FilterFunction, type ViewAction, type ViewCategoryAction, viewId } from '@hcengineering/view'
import { type BlobMetadata, type FilePreviewExtension } from '@hcengineering/presentation'
import { type PresentationMiddlewareFactory } from '@hcengineering/presentation/src/pipeline' import { type PresentationMiddlewareFactory } from '@hcengineering/presentation/src/pipeline'
import view from '@hcengineering/view-resources/src/plugin' import view from '@hcengineering/view-resources/src/plugin'
@ -82,7 +83,11 @@ export default mergeIds(viewId, view, {
StatusPresenter: '' as AnyComponent, StatusPresenter: '' as AnyComponent,
StatusRefPresenter: '' as AnyComponent, StatusRefPresenter: '' as AnyComponent,
DateFilterPresenter: '' as AnyComponent, DateFilterPresenter: '' as AnyComponent,
StringFilterPresenter: '' as AnyComponent StringFilterPresenter: '' as AnyComponent,
AudioViewer: '' as AnyComponent,
ImageViewer: '' as AnyComponent,
VideoViewer: '' as AnyComponent,
PDFViewer: '' as AnyComponent
}, },
string: { string: {
Table: '' as IntlString, Table: '' as IntlString,
@ -132,10 +137,18 @@ export default mergeIds(viewId, view, {
CanArchiveSpace: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>, CanArchiveSpace: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
CanDeleteSpace: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>, CanDeleteSpace: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
CanJoinSpace: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>, CanJoinSpace: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
CanLeaveSpace: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>> CanLeaveSpace: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
BlobImageMetadata: '' as Resource<(file: File, blob: Ref<Blob>) => Promise<BlobMetadata | undefined>>,
BlobVideoMetadata: '' as Resource<(file: File, blob: Ref<Blob>) => Promise<BlobMetadata | undefined>>
}, },
pipeline: { pipeline: {
PresentationMiddleware: '' as Ref<PresentationMiddlewareFactory>, PresentationMiddleware: '' as Ref<PresentationMiddlewareFactory>,
AnalyticsMiddleware: '' as Ref<PresentationMiddlewareFactory> AnalyticsMiddleware: '' as Ref<PresentationMiddlewareFactory>
},
extension: {
Audio: '' as Ref<FilePreviewExtension>,
Image: '' as Ref<FilePreviewExtension>,
Video: '' as Ref<FilePreviewExtension>,
PDF: '' as Ref<FilePreviewExtension>
} }
}) })

View File

@ -31,6 +31,11 @@
"Created": "Created", "Created": "Created",
"NoResults": "No results to show", "NoResults": "No results to show",
"Next": "Next", "Next": "Next",
"FailedToPreview": "Failed to preview" "FailedToPreview": "Failed to preview",
"ContentType": "Content type",
"ContentTypeNotSupported": "Content type not supported"
},
"status": {
"FileTooLarge": "File too large"
} }
} }

View File

@ -31,6 +31,11 @@
"Created": "Creado", "Created": "Creado",
"NoResults": "No hay resultados para mostrar", "NoResults": "No hay resultados para mostrar",
"Next": "Siguiente", "Next": "Siguiente",
"FailedToPreview": "Error al previsualizar" "FailedToPreview": "Error al previsualizar",
"ContentType": "Tipo de contenido",
"ContentTypeNotSupported": "Tipo de contenido no admitido"
},
"status": {
"FileTooLarge": "Archivo demasiado grande"
} }
} }

View File

@ -31,6 +31,11 @@
"Created": "Criado", "Created": "Criado",
"NoResults": "Sem resultados para mostrar", "NoResults": "Sem resultados para mostrar",
"Next": "Seguinte", "Next": "Seguinte",
"FailedToPreview": "Falha ao pré-visualizar" "FailedToPreview": "Falha ao pré-visualizar",
"ContentType": "Tipo de conteúdo",
"ContentTypeNotSupported": "Tipo de conteúdo não suportado"
},
"status": {
"FileTooLarge": "Ficheiro demasiado grande"
} }
} }

View File

@ -31,6 +31,11 @@
"Created": "Созданные", "Created": "Созданные",
"NoResults": "Нет результатов", "NoResults": "Нет результатов",
"Next": "Далее", "Next": "Далее",
"FailedToPreview": "Ошибка предпросмотра" "FailedToPreview": "Ошибка предпросмотра",
"ContentType": "Тип контента",
"ContentTypeNotSupported": "Тип контента не поддерживается"
},
"status": {
"FileTooLarge": "Файл слишком большой"
} }
} }

View File

@ -0,0 +1,151 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte'
import { type Blob, type Ref } from '@hcengineering/core'
import { Label, Dialog, Button, Component } from '@hcengineering/ui'
import presentation from '../plugin'
import { getPreviewType, previewTypes } from '../file'
import { BlobMetadata, FilePreviewExtension } from '../types'
import { getFileUrl } from '../utils'
import ActionContext from './ActionContext.svelte'
import Download from './icons/Download.svelte'
export let file: Ref<Blob> | undefined
export let name: string
export let contentType: string
export let metadata: BlobMetadata | undefined
export let props: Record<string, any> = {}
export let fullSize = false
export let showIcon = true
const dispatch = createEventDispatcher()
onMount(() => {
if (fullSize) {
dispatch('fullsize')
}
})
function iconLabel (name: string): string {
const parts = `${name}`.split('.')
const ext = parts[parts.length - 1]
return ext.substring(0, 4).toUpperCase()
}
let previewType: FilePreviewExtension | undefined = undefined
$: if (file !== undefined) {
void getPreviewType(contentType, $previewTypes).then((res) => {
previewType = res
})
} else {
previewType = undefined
}
let download: HTMLAnchorElement
$: src = file === undefined ? '' : getFileUrl(file, 'full', name)
</script>
<ActionContext context={{ mode: 'browser' }} />
<Dialog
isFullSize
on:fullsize
on:close={() => {
dispatch('close')
}}
>
<svelte:fragment slot="title">
<div class="antiTitle icon-wrapper">
{#if showIcon}
<div class="wrapped-icon">
<div class="flex-center icon">
{iconLabel(name)}
</div>
</div>
{/if}
<span class="wrapped-title">{name}</span>
</div>
</svelte:fragment>
<svelte:fragment slot="utils">
{#if src !== ''}
<a class="no-line" href={src} download={name} bind:this={download}>
<Button
icon={Download}
kind={'ghost'}
on:click={() => {
download.click()
}}
showTooltip={{ label: presentation.string.Download }}
/>
</a>
{/if}
</svelte:fragment>
{#if src === ''}
<div class="centered">
<Label label={presentation.string.FailedToPreview} />
</div>
{:else if previewType !== undefined}
<div class="content flex-col flex-grow">
<Component is={previewType.component} props={{ value: file, name, contentType, metadata, ...props }} />
</div>
{:else}
<div class="centered flex-col flex-gap-3">
<Label label={presentation.string.ContentTypeNotSupported} />
<Button
label={presentation.string.Download}
kind={'primary'}
on:click={() => {
download.click()
}}
showTooltip={{ label: presentation.string.Download }}
/>
</div>
{/if}
</Dialog>
<style lang="scss">
.icon {
position: relative;
flex-shrink: 0;
width: 2rem;
height: 2rem;
font-weight: 500;
font-size: 0.625rem;
color: var(--primary-button-color);
background-color: var(--primary-button-default);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 0.5rem;
cursor: pointer;
}
.content {
flex-grow: 1;
overflow: auto;
border: none;
}
.centered {
flex-grow: 1;
width: 100;
height: 100;
display: flex;
justify-content: center;
align-items: center;
}
</style>

View File

@ -1,5 +1,5 @@
<!-- <!--
// Copyright © 2020 Anticrm Platform Contributors. // Copyright © 2024 Hardcore Engineering Inc.
// //
// Licensed under the Eclipse Public License, Version 2.0 (the "License"); // Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may // you may not use this file except in compliance with the License. You may

View File

@ -0,0 +1,179 @@
//
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { type Blob, type Ref, concatLink } from '@hcengineering/core'
import { PlatformError, Severity, Status, getMetadata, getResource } from '@hcengineering/platform'
import { type PopupAlignment } from '@hcengineering/ui'
import { writable } from 'svelte/store'
import { type BlobMetadata, type FilePreviewExtension } from './types'
import { createQuery } from './utils'
import plugin from './plugin'
/**
* @public
*/
export async function uploadFile (file: File): Promise<Ref<Blob>> {
const uploadUrl = getMetadata(plugin.metadata.UploadURL)
if (uploadUrl === undefined) {
throw Error('UploadURL is not defined')
}
const data = new FormData()
data.append('file', file)
const resp = await fetch(uploadUrl, {
method: 'POST',
headers: {
Authorization: 'Bearer ' + (getMetadata(plugin.metadata.Token) as string)
},
body: data
})
if (resp.status !== 200) {
if (resp.status === 413) {
throw new PlatformError(new Status(Severity.ERROR, plugin.status.FileTooLarge, {}))
} else {
throw Error(`Failed to upload file: ${resp.statusText}`)
}
}
return (await resp.text()) as Ref<Blob>
}
/**
* @public
*/
export async function deleteFile (id: string): Promise<void> {
const uploadUrl = getMetadata(plugin.metadata.UploadURL) ?? ''
const url = concatLink(uploadUrl, `?file=${id}`)
const resp = await fetch(url, {
method: 'DELETE',
headers: {
Authorization: 'Bearer ' + (getMetadata(plugin.metadata.Token) as string)
}
})
if (resp.status !== 200) {
throw new Error('Failed to delete file')
}
}
/**
* @public
*/
export async function getFileMetadata (file: File, uuid: Ref<Blob>): Promise<BlobMetadata | undefined> {
const previewType = await getPreviewType(file.type, $previewTypes)
if (previewType?.metadataProvider === undefined) {
return undefined
}
const metadataProvider = await getResource(previewType.metadataProvider)
if (metadataProvider === undefined) {
return undefined
}
return await metadataProvider(file, uuid)
}
/**
* @public
*/
export const previewTypes = writable<FilePreviewExtension[]>([])
const previewTypesQuery = createQuery(true)
previewTypesQuery.query(plugin.class.FilePreviewExtension, {}, (result) => {
previewTypes.set(result)
})
let $previewTypes: FilePreviewExtension[] = []
previewTypes.subscribe((it) => {
$previewTypes = it
})
/**
* @public
*/
export async function canPreviewFile (contentType: string, _previewTypes: FilePreviewExtension[]): Promise<boolean> {
for (const previewType of _previewTypes) {
if (await isApplicableType(previewType, contentType)) {
return true
}
}
return false
}
/**
* @public
*/
export async function getPreviewType (
contentType: string,
_previewTypes: FilePreviewExtension[]
): Promise<FilePreviewExtension | undefined> {
const applicableTypes: FilePreviewExtension[] = []
for (const previewType of _previewTypes) {
if (await isApplicableType(previewType, contentType)) {
applicableTypes.push(previewType)
}
}
return applicableTypes.sort(comparePreviewTypes)[0]
}
/**
* @public
*/
export function getPreviewAlignment (contentType: string): PopupAlignment {
if (contentType.startsWith('image/')) {
return 'centered'
} else if (contentType.startsWith('video/')) {
return 'centered'
} else {
return 'float'
}
}
function getPreviewTypeRegExp (type: string): RegExp {
return new RegExp(`^${type.replaceAll('/', '\\/').replaceAll('*', '.*')}$`)
}
async function isApplicableType (
{ contentType, availabilityChecker }: FilePreviewExtension,
_contentType: string
): Promise<boolean> {
const checkAvailability = availabilityChecker !== undefined ? await getResource(availabilityChecker) : undefined
const isAvailable: boolean = checkAvailability === undefined || (await checkAvailability())
return (
isAvailable &&
(Array.isArray(contentType) ? contentType : [contentType]).some((type) =>
getPreviewTypeRegExp(type).test(_contentType)
)
)
}
function comparePreviewTypes (a: FilePreviewExtension, b: FilePreviewExtension): number {
if (a.order === undefined && b.order === undefined) {
return 0
} else if (a.order === undefined) {
return -1
} else if (b.order === undefined) {
return 1
} else {
return a.order - b.order
}
}

View File

@ -39,6 +39,7 @@ export { default as IndexedDocumentPreview } from './components/IndexedDocumentP
export { default as IndexedDocumentCompare } from './components/IndexedDocumentCompare.svelte' export { default as IndexedDocumentCompare } from './components/IndexedDocumentCompare.svelte'
export { default as NavLink } from './components/NavLink.svelte' export { default as NavLink } from './components/NavLink.svelte'
export { default as IconDownload } from './components/icons/Download.svelte' export { default as IconDownload } from './components/icons/Download.svelte'
export { default as FilePreviewPopup } from './components/FilePreviewPopup.svelte'
export { default as IconForward } from './components/icons/Forward.svelte' export { default as IconForward } from './components/icons/Forward.svelte'
export { default as Breadcrumbs } from './components/breadcrumbs/Breadcrumbs.svelte' export { default as Breadcrumbs } from './components/breadcrumbs/Breadcrumbs.svelte'
export { default as BreadcrumbsElement } from './components/breadcrumbs/BreadcrumbsElement.svelte' export { default as BreadcrumbsElement } from './components/breadcrumbs/BreadcrumbsElement.svelte'
@ -48,6 +49,7 @@ export { default as SearchResult } from './components/SearchResult.svelte'
export { default } from './plugin' export { default } from './plugin'
export * from './types' export * from './types'
export * from './utils' export * from './utils'
export * from './file'
export * from './drafts' export * from './drafts'
export { presentationId } export { presentationId }
export * from './collaborator' export * from './collaborator'

View File

@ -15,13 +15,15 @@
// //
import { type Class, type Ref } from '@hcengineering/core' import { type Class, type Ref } from '@hcengineering/core'
import type { Asset, IntlString, Metadata, Plugin } from '@hcengineering/platform' import type { Asset, IntlString, Metadata, Plugin, StatusCode } from '@hcengineering/platform'
import { plugin } from '@hcengineering/platform' import { plugin } from '@hcengineering/platform'
import { type ComponentExtensionId } from '@hcengineering/ui'
import { type PresentationMiddlewareFactory } from './pipeline' import { type PresentationMiddlewareFactory } from './pipeline'
import { import {
type ComponentPointExtension, type ComponentPointExtension,
type DocRules, type DocRules,
type DocCreateExtension, type DocCreateExtension,
type FilePreviewExtension,
type ObjectSearchCategory type ObjectSearchCategory
} from './types' } from './types'
@ -36,7 +38,8 @@ export default plugin(presentationId, {
PresentationMiddlewareFactory: '' as Ref<Class<PresentationMiddlewareFactory>>, PresentationMiddlewareFactory: '' as Ref<Class<PresentationMiddlewareFactory>>,
ComponentPointExtension: '' as Ref<Class<ComponentPointExtension>>, ComponentPointExtension: '' as Ref<Class<ComponentPointExtension>>,
DocCreateExtension: '' as Ref<Class<DocCreateExtension>>, DocCreateExtension: '' as Ref<Class<DocCreateExtension>>,
DocRules: '' as Ref<Class<DocRules>> DocRules: '' as Ref<Class<DocRules>>,
FilePreviewExtension: '' as Ref<Class<FilePreviewExtension>>
}, },
string: { string: {
Create: '' as IntlString, Create: '' as IntlString,
@ -72,7 +75,12 @@ export default plugin(presentationId, {
Created: '' as IntlString, Created: '' as IntlString,
NoResults: '' as IntlString, NoResults: '' as IntlString,
Next: '' as IntlString, Next: '' as IntlString,
FailedToPreview: '' as IntlString FailedToPreview: '' as IntlString,
ContentType: '' as IntlString,
ContentTypeNotSupported: '' as IntlString
},
extension: {
FilePreviewExtension: '' as ComponentExtensionId
}, },
metadata: { metadata: {
RequiredVersion: '' as Metadata<string>, RequiredVersion: '' as Metadata<string>,
@ -82,5 +90,8 @@ export default plugin(presentationId, {
CollaboratorApiUrl: '' as Metadata<string>, CollaboratorApiUrl: '' as Metadata<string>,
Token: '' as Metadata<string>, Token: '' as Metadata<string>,
FrontUrl: '' as Asset FrontUrl: '' as Asset
},
status: {
FileTooLarge: '' as StatusCode
} }
}) })

View File

@ -1,4 +1,5 @@
import { import {
type Blob,
type Class, type Class,
type Client, type Client,
type Doc, type Doc,
@ -166,3 +167,19 @@ export interface DocRules extends Doc {
mixin?: Ref<Mixin<Space>> mixin?: Ref<Mixin<Space>>
} }
} }
/**
* @public
*/
export type BlobMetadata = Record<string, any>
/**
* @public
*/
export interface FilePreviewExtension extends ComponentPointExtension {
contentType: string | string[]
alignment?: string
metadataProvider?: Resource<(file: File, blob: Ref<Blob>) => Promise<BlobMetadata | undefined>>
// Extension is only available if this checker returns true
availabilityChecker?: Resource<() => Promise<boolean>>
}

View File

@ -15,7 +15,7 @@
<script lang="ts"> <script lang="ts">
import { createEventDispatcher } from 'svelte' import { createEventDispatcher } from 'svelte'
import { getEmbeddedLabel } from '@hcengineering/platform' import { getEmbeddedLabel } from '@hcengineering/platform'
import { PDFViewer, getFileUrl } from '@hcengineering/presentation' import { FilePreviewPopup, getFileUrl } from '@hcengineering/presentation'
import { IconExpand, IconMoreH, IconSize, SelectPopup, getEventPositionElement, showPopup } from '@hcengineering/ui' import { IconExpand, IconMoreH, IconSize, SelectPopup, getEventPositionElement, showPopup } from '@hcengineering/ui'
import { Editor } from '@tiptap/core' import { Editor } from '@tiptap/core'
import IconAlignCenter from './icons/AlignCenter.svelte' import IconAlignCenter from './icons/AlignCenter.svelte'
@ -42,7 +42,7 @@
const fileId = attributes['file-id'] ?? attributes.src const fileId = attributes['file-id'] ?? attributes.src
const fileName = attributes.alt ?? '' const fileName = attributes.alt ?? ''
showPopup( showPopup(
PDFViewer, FilePreviewPopup,
{ file: fileId, name: fileName, contentType: 'image/*', fullSize: true, showIcon: false }, { file: fileId, name: fileName, contentType: 'image/*', fullSize: true, showIcon: false },
'centered', 'centered',
() => { () => {

View File

@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
// //
import { getFileUrl, PDFViewer } from '@hcengineering/presentation' import { FilePreviewPopup, getFileUrl } from '@hcengineering/presentation'
import { FileNode, type FileOptions as FileNodeOptions } from '@hcengineering/text' import { FileNode, type FileOptions as FileNodeOptions } from '@hcengineering/text'
import { showPopup } from '@hcengineering/ui' import { showPopup } from '@hcengineering/ui'
import { nodeInputRule } from '@tiptap/core' import { nodeInputRule } from '@tiptap/core'
@ -123,15 +123,14 @@ export const FileExtension = FileNode.extend<FileOptions>({
if (fileId === '') return if (fileId === '') return
const fileName = node.attrs['data-file-name'] ?? '' const fileName = node.attrs['data-file-name'] ?? ''
const fileType: string = node.attrs['data-file-type'] ?? '' const fileType: string = node.attrs['data-file-type'] ?? ''
if (!(fileType.startsWith('image/') || fileType === 'text/plain' || fileType === 'application/pdf')) return
showPopup( showPopup(
PDFViewer, FilePreviewPopup,
{ {
file: fileId, file: fileId,
name: fileName, name: fileName,
contentType: fileType, contentType: fileType,
fullSize: true, fullSize: false,
showIcon: false showIcon: false
}, },
'centered' 'centered'

View File

@ -12,10 +12,9 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
// //
import { PDFViewer } from '@hcengineering/presentation' import { FilePreviewPopup } from '@hcengineering/presentation'
import { showPopup } from '@hcengineering/ui'
import { ImageNode, type ImageOptions as ImageNodeOptions } from '@hcengineering/text' import { ImageNode, type ImageOptions as ImageNodeOptions } from '@hcengineering/text'
import { type IconSize, getIconSize2x } from '@hcengineering/ui' import { type IconSize, getIconSize2x, showPopup } from '@hcengineering/ui'
import { mergeAttributes, nodeInputRule } from '@tiptap/core' import { mergeAttributes, nodeInputRule } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state' import { Plugin, PluginKey } from '@tiptap/pm/state'
@ -201,7 +200,7 @@ export const ImageExtension = ImageNode.extend<ImageOptions>({
const fileName = node.attrs.alt ?? '' const fileName = node.attrs.alt ?? ''
showPopup( showPopup(
PDFViewer, FilePreviewPopup,
{ {
file: fileId, file: fileId,
name: fileName, name: fileName,

View File

@ -53,8 +53,5 @@
"FilterAttachments": "Attachments", "FilterAttachments": "Attachments",
"RemovedAttachment": "Removed attachment", "RemovedAttachment": "Removed attachment",
"ContentType": "Content type" "ContentType": "Content type"
},
"status": {
"FileTooLarge": "File too large"
} }
} }

View File

@ -53,8 +53,5 @@
"FilterAttachments": "Adjuntos", "FilterAttachments": "Adjuntos",
"RemovedAttachment": "Adjunto eliminado", "RemovedAttachment": "Adjunto eliminado",
"ContentType": "Tipo de contenido" "ContentType": "Tipo de contenido"
},
"status": {
"FileTooLarge": "Archivo demasiado grande"
} }
} }

View File

@ -53,8 +53,5 @@
"FilterAttachments": "Anexos", "FilterAttachments": "Anexos",
"RemovedAttachment": "Anexo removido", "RemovedAttachment": "Anexo removido",
"ContentType": "Tipo de conteúdo" "ContentType": "Tipo de conteúdo"
},
"status": {
"FileTooLarge": "Ficheiro demasiado grande"
} }
} }

View File

@ -53,8 +53,5 @@
"FilterAttachments": "Вложения", "FilterAttachments": "Вложения",
"RemovedAttachment": "Удалил(а) вложение", "RemovedAttachment": "Удалил(а) вложение",
"ContentType": "Тип контента" "ContentType": "Тип контента"
},
"status": {
"FileTooLarge": "Файл слишком большой"
} }
} }

View File

@ -13,22 +13,18 @@
// limitations under the License. // limitations under the License.
--> -->
<script lang="ts"> <script lang="ts">
import { AttachmentPreviewExtension, type Attachment } from '@hcengineering/attachment' import { type Attachment } from '@hcengineering/attachment'
import { getResource } from '@hcengineering/platform' import { getResource } from '@hcengineering/platform'
import { getFileUrl } from '@hcengineering/presentation'
import { import {
Action as UIAction, FilePreviewPopup,
ActionIcon, getFileUrl,
IconMoreH, previewTypes,
IconOpen, canPreviewFile,
Menu, getPreviewAlignment
closeTooltip, } from '@hcengineering/presentation'
showPopup, import { Action as UIAction, ActionIcon, IconMoreH, IconOpen, Menu, closeTooltip, showPopup } from '@hcengineering/ui'
PopupAlignment
} from '@hcengineering/ui'
import view, { Action } from '@hcengineering/view' import view, { Action } from '@hcengineering/view'
import { previewTypes, getPreviewType, isOpenable } from '../utils'
import attachmentPlugin from '../plugin' import attachmentPlugin from '../plugin'
import FileDownload from './icons/FileDownload.svelte' import FileDownload from './icons/FileDownload.svelte'
@ -39,24 +35,18 @@
let download: HTMLAnchorElement let download: HTMLAnchorElement
$: contentType = attachment?.type ?? '' $: contentType = attachment?.type ?? ''
let openable = false
$: {
void isOpenable(contentType, $previewTypes).then((res) => {
openable = res
})
}
let previewType: AttachmentPreviewExtension | undefined = undefined let canPreview: boolean = false
$: if (openable) { $: if (attachment !== undefined) {
void getPreviewType(contentType, $previewTypes).then((res) => { void canPreviewFile(contentType, $previewTypes).then((res) => {
previewType = res canPreview = res
}) })
} else { } else {
previewType = undefined canPreview = false
} }
function showPreview (e: MouseEvent): void { function showPreview (e: MouseEvent): void {
if (!openable || previewType === undefined) { if (!canPreview) {
return return
} }
@ -69,9 +59,14 @@
closeTooltip() closeTooltip()
showPopup( showPopup(
previewType.component, FilePreviewPopup,
{ ...(previewType.props ?? {}), file: attachment.file, name: attachment.name, contentType, value: attachment }, {
(previewType.alignment ?? 'center') as PopupAlignment file: attachment.file,
name: attachment.name,
contentType: attachment.type ?? '',
metadata: attachment.metadata
},
getPreviewAlignment(attachment.type ?? '')
) )
} }
@ -95,7 +90,7 @@
const showMenu = (ev: Event) => { const showMenu = (ev: Event) => {
const actions: UIAction[] = [] const actions: UIAction[] = []
if (openable) { if (canPreview) {
actions.push(openAction) actions.push(openAction)
} }
actions.push({ actions.push({
@ -133,7 +128,7 @@
bind:this={download} bind:this={download}
on:click|stopPropagation on:click|stopPropagation
> >
{#if openable} {#if canPreview}
<ActionIcon <ActionIcon
icon={IconOpen} icon={IconOpen}
size={'medium'} size={'medium'}

View File

@ -15,8 +15,7 @@
<script lang="ts"> <script lang="ts">
import type { Attachment } from '@hcengineering/attachment' import type { Attachment } from '@hcengineering/attachment'
import { showPopup, closeTooltip } from '@hcengineering/ui' import { showPopup, closeTooltip } from '@hcengineering/ui'
import { PDFViewer, getFileUrl } from '@hcengineering/presentation' import { FilePreviewPopup, getFileUrl } from '@hcengineering/presentation'
import MediaViewer from './MediaViewer.svelte'
import { getType } from '../utils' import { getType } from '../utils'
import filesize from 'filesize' import filesize from 'filesize'
@ -32,22 +31,24 @@
return ext.substring(0, 4).toUpperCase() return ext.substring(0, 4).toUpperCase()
} }
function isPlayable (contentType: string) { function isImage (contentType: string): boolean {
const type = getType(contentType)
return type === 'video' || type === 'audio'
}
function isImage (contentType: string) {
return getType(contentType) === 'image' return getType(contentType) === 'image'
} }
function isEmbedded (contentType: string) {
function isEmbedded (contentType: string): boolean {
return getType(contentType) !== 'other' return getType(contentType) !== 'other'
} }
function openAttachment () { function openAttachment (): void {
closeTooltip() closeTooltip()
showPopup( showPopup(
isPlayable(value.type) ? MediaViewer : PDFViewer, FilePreviewPopup,
{ file: value.file, name: value.name, contentType: value.type, value }, {
file: value.file,
name: value.name,
contentType: value.type,
metadata: value.metadata
},
isImage(value.type) ? 'centered' : 'float' isImage(value.type) ? 'centered' : 'float'
) )
} }

View File

@ -16,13 +16,12 @@
<script lang="ts"> <script lang="ts">
import type { Doc } from '@hcengineering/core' import type { Doc } from '@hcengineering/core'
import { Attachment } from '@hcengineering/attachment' import { Attachment } from '@hcengineering/attachment'
import { createQuery, getClient } from '@hcengineering/presentation' import { createQuery, getClient, getFileMetadata, uploadFile } from '@hcengineering/presentation'
import { ActionIcon, IconAdd, Label, Loading } from '@hcengineering/ui' import { ActionIcon, IconAdd, Label, Loading } from '@hcengineering/ui'
import { setPlatformStatus, unknownError } from '@hcengineering/platform' import { setPlatformStatus, unknownError } from '@hcengineering/platform'
import { AttachmentPresenter } from '..' import { AttachmentPresenter } from '..'
import attachment from '../plugin' import attachment from '../plugin'
import { getAttachmentMetadata, uploadFile } from '../utils'
// export let attachments: number // export let attachments: number
export let object: Doc export let object: Doc
@ -55,7 +54,7 @@
async function createAttachment (file: File) { async function createAttachment (file: File) {
try { try {
const uuid = await uploadFile(file) const uuid = await uploadFile(file)
const metadata = await getAttachmentMetadata(file, uuid) const metadata = await getFileMetadata(file, uuid)
await client.addCollection(attachment.class.Attachment, object.space, object._id, object._class, 'attachments', { await client.addCollection(attachment.class.Attachment, object.space, object._id, object._class, 'attachments', {
name: file.name, name: file.name,

View File

@ -14,14 +14,20 @@
// limitations under the License. // limitations under the License.
--> -->
<script lang="ts"> <script lang="ts">
import { createEventDispatcher } from 'svelte'
import type { Attachment, AttachmentPreviewExtension } from '@hcengineering/attachment'
import { showPopup, closeTooltip, Label, getIconSize2x, Loading, PopupAlignment } from '@hcengineering/ui'
import presentation, { getFileUrl } from '@hcengineering/presentation'
import filesize from 'filesize' import filesize from 'filesize'
import { createEventDispatcher } from 'svelte'
import type { Attachment } from '@hcengineering/attachment'
import core from '@hcengineering/core' import core from '@hcengineering/core'
import { showPopup, closeTooltip, Label, getIconSize2x, Loading } from '@hcengineering/ui'
import presentation, {
FilePreviewPopup,
canPreviewFile,
getFileUrl,
getPreviewAlignment,
previewTypes
} from '@hcengineering/presentation'
import { permissionsStore } from '@hcengineering/view-resources' import { permissionsStore } from '@hcengineering/view-resources'
import { getType, isOpenable, previewTypes, getPreviewType } from '../utils' import { getType } from '../utils'
import AttachmentName from './AttachmentName.svelte' import AttachmentName from './AttachmentName.svelte'
@ -54,28 +60,18 @@
return getType(contentType) === 'image' return getType(contentType) === 'image'
} }
let openable = false let canPreview: boolean = false
$: if (value !== undefined) { $: if (value !== undefined) {
void isOpenable(value.type, $previewTypes).then((res) => { void canPreviewFile(value.type, $previewTypes).then((res) => {
openable = res canPreview = res
}) })
} else { } else {
openable = false canPreview = false
}
let previewType: AttachmentPreviewExtension | undefined = undefined
$: if (openable && value !== undefined) {
void getPreviewType(value.type, $previewTypes).then((res) => {
previewType = res
})
} else {
previewType = undefined
} }
function clickHandler (e: MouseEvent): void { function clickHandler (e: MouseEvent): void {
if (value === undefined) return if (value === undefined || !canPreview) return
if (!openable || previewType === undefined) return
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
if (e.metaKey || e.ctrlKey) { if (e.metaKey || e.ctrlKey) {
@ -84,9 +80,14 @@
} }
closeTooltip() closeTooltip()
showPopup( showPopup(
previewType.component, FilePreviewPopup,
{ ...(previewType.props ?? {}), file: value.file, name: value.name, contentType: value.type, value }, {
(previewType.alignment ?? 'center') as PopupAlignment file: value.file,
name: value.name,
contentType: value.type,
metadata: value.metadata
},
getPreviewAlignment(value.type)
) )
} }

View File

@ -15,7 +15,7 @@
--> -->
<script lang="ts"> <script lang="ts">
import type { Attachment } from '@hcengineering/attachment' import type { Attachment } from '@hcengineering/attachment'
import { PDFViewer } from '@hcengineering/presentation' import { FilePreviewPopup } from '@hcengineering/presentation'
import { showPopup, closeTooltip } from '@hcengineering/ui' import { showPopup, closeTooltip } from '@hcengineering/ui'
import { ListSelectionProvider } from '@hcengineering/view-resources' import { ListSelectionProvider } from '@hcengineering/view-resources'
import { createEventDispatcher } from 'svelte' import { createEventDispatcher } from 'svelte'
@ -49,7 +49,7 @@
closeTooltip() closeTooltip()
if (listProvider !== undefined) listProvider.updateFocus(value) if (listProvider !== undefined) listProvider.updateFocus(value)
const popupInfo = showPopup( const popupInfo = showPopup(
PDFViewer, FilePreviewPopup,
{ file: value.file, name: value.name, contentType: value.type }, { file: value.file, name: value.name, contentType: value.type },
value.type.startsWith('image/') ? 'centered' : 'float' value.type.startsWith('image/') ? 'centered' : 'float'
) )

View File

@ -17,10 +17,17 @@
import { Attachment } from '@hcengineering/attachment' import { Attachment } from '@hcengineering/attachment'
import { Account, Class, Doc, generateId, IdMap, Markup, Ref, Space, toIdMap } from '@hcengineering/core' import { Account, Class, Doc, generateId, IdMap, Markup, Ref, Space, toIdMap } from '@hcengineering/core'
import { IntlString, setPlatformStatus, unknownError, Asset } from '@hcengineering/platform' import { IntlString, setPlatformStatus, unknownError, Asset } from '@hcengineering/platform'
import { createQuery, DraftController, draftsStore, getClient } from '@hcengineering/presentation' import {
createQuery,
DraftController,
deleteFile,
draftsStore,
getClient,
getFileMetadata,
uploadFile
} from '@hcengineering/presentation'
import textEditor, { AttachIcon, EmptyMarkup, type RefAction, ReferenceInput } from '@hcengineering/text-editor' import textEditor, { AttachIcon, EmptyMarkup, type RefAction, ReferenceInput } from '@hcengineering/text-editor'
import { Loading, type AnySvelteComponent } from '@hcengineering/ui' import { Loading, type AnySvelteComponent } from '@hcengineering/ui'
import { deleteFile, getAttachmentMetadata, uploadFile } from '../utils'
import attachment from '../plugin' import attachment from '../plugin'
import AttachmentPresenter from './AttachmentPresenter.svelte' import AttachmentPresenter from './AttachmentPresenter.svelte'
@ -104,7 +111,7 @@
async function createAttachment (file: File) { async function createAttachment (file: File) {
try { try {
const uuid = await uploadFile(file) const uuid = await uploadFile(file)
const metadata = await getAttachmentMetadata(file, uuid) const metadata = await getFileMetadata(file, uuid)
const _id: Ref<Attachment> = generateId() const _id: Ref<Attachment> = generateId()
attachments.set(_id, { attachments.set(_id, {

View File

@ -17,7 +17,7 @@
import contact from '@hcengineering/contact' import contact from '@hcengineering/contact'
import { Account, Doc, Ref, generateId } from '@hcengineering/core' import { Account, Doc, Ref, generateId } from '@hcengineering/core'
import { IntlString, getResource, setPlatformStatus, unknownError } from '@hcengineering/platform' import { IntlString, getResource, setPlatformStatus, unknownError } from '@hcengineering/platform'
import { KeyedAttribute, createQuery, getClient } from '@hcengineering/presentation' import { KeyedAttribute, createQuery, getClient, uploadFile } from '@hcengineering/presentation'
import { getCollaborationUser, getObjectLinkFragment } from '@hcengineering/view-resources' import { getCollaborationUser, getObjectLinkFragment } from '@hcengineering/view-resources'
import textEditor, { import textEditor, {
AttachIcon, AttachIcon,
@ -32,7 +32,6 @@
import { defaultRefActions, getModelRefActions } from '@hcengineering/text-editor/src/components/editor/actions' import { defaultRefActions, getModelRefActions } from '@hcengineering/text-editor/src/components/editor/actions'
import AttachmentsGrid from './AttachmentsGrid.svelte' import AttachmentsGrid from './AttachmentsGrid.svelte'
import { uploadFile } from '../utils'
export let object: Doc export let object: Doc
export let key: KeyedAttribute export let key: KeyedAttribute

View File

@ -17,12 +17,19 @@
import { Attachment } from '@hcengineering/attachment' import { Attachment } from '@hcengineering/attachment'
import { Account, Class, Doc, generateId, Markup, Ref, Space, toIdMap } from '@hcengineering/core' import { Account, Class, Doc, generateId, Markup, Ref, Space, toIdMap } from '@hcengineering/core'
import { IntlString, setPlatformStatus, unknownError } from '@hcengineering/platform' import { IntlString, setPlatformStatus, unknownError } from '@hcengineering/platform'
import { createQuery, DraftController, draftsStore, getClient } from '@hcengineering/presentation' import {
createQuery,
DraftController,
deleteFile,
draftsStore,
getClient,
getFileMetadata,
uploadFile
} from '@hcengineering/presentation'
import textEditor, { AttachIcon, EmptyMarkup, type RefAction, StyledTextBox } from '@hcengineering/text-editor' import textEditor, { AttachIcon, EmptyMarkup, type RefAction, StyledTextBox } from '@hcengineering/text-editor'
import { ButtonSize } from '@hcengineering/ui' import { ButtonSize } from '@hcengineering/ui'
import attachment from '../plugin' import attachment from '../plugin'
import { deleteFile, getAttachmentMetadata, uploadFile } from '../utils'
import AttachmentsGrid from './AttachmentsGrid.svelte' import AttachmentsGrid from './AttachmentsGrid.svelte'
export let objectId: Ref<Doc> | undefined = undefined export let objectId: Ref<Doc> | undefined = undefined
@ -134,7 +141,7 @@
if (space === undefined || objectId === undefined || _class === undefined) return if (space === undefined || objectId === undefined || _class === undefined) return
try { try {
const uuid = await uploadFile(file) const uuid = await uploadFile(file)
const metadata = await getAttachmentMetadata(file, uuid) const metadata = await getFileMetadata(file, uuid)
const _id: Ref<Attachment> = generateId() const _id: Ref<Attachment> = generateId()
attachments.set(_id, { attachments.set(_id, {

View File

@ -1,111 +0,0 @@
<!--
// Copyright © 2020 Anticrm Platform Contributors.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
// import { Doc } from '@hcengineering/core'
import { Button, Dialog } from '@hcengineering/ui'
import type { Attachment } from '@hcengineering/attachment'
import AudioPlayer from './AudioPlayer.svelte'
import { createEventDispatcher, onMount } from 'svelte'
import presentation, { ActionContext, PDFViewer, IconDownload, getFileUrl } from '@hcengineering/presentation'
import { getType } from '../utils'
export let value: Attachment
export let showIcon = true
export let fullSize = false
const dispatch = createEventDispatcher()
function iconLabel (name: string): string {
const parts = name.split('.')
const ext = parts[parts.length - 1]
return ext.substring(0, 4).toUpperCase()
}
onMount(() => {
if (fullSize) {
dispatch('fullsize')
}
})
let download: HTMLAnchorElement
$: type = getType(value.type)
$: src = getFileUrl(value.file, 'full', value.name)
</script>
<ActionContext context={{ mode: 'browser' }} />
<Dialog
isFullSize
on:fullsize
on:close={() => {
dispatch('close')
}}
>
<svelte:fragment slot="title">
<div class="antiTitle icon-wrapper">
{#if showIcon}
<div class="wrapped-icon">
<div class="flex-center icon">
{iconLabel(value.name)}
</div>
</div>
{/if}
<span class="wrapped-title">{value.name}</span>
</div>
</svelte:fragment>
<svelte:fragment slot="utils">
<a class="no-line" href={src} download={value.name} bind:this={download}>
<Button
icon={IconDownload}
kind={'ghost'}
on:click={() => {
download.click()
}}
showTooltip={{ label: presentation.string.Download }}
/>
</a>
</svelte:fragment>
{#if type === 'video'}
<video controls preload={'auto'}>
<source {src} />
<track kind="captions" label={value.name} />
</video>
{:else if type === 'audio'}
<AudioPlayer {value} fullSize={true} />
{:else}
<iframe class="pdfviewer-content" src={src + '#view=FitH&navpanes=0'} title="" />
{/if}
</Dialog>
<style lang="scss">
.icon {
position: relative;
flex-shrink: 0;
width: 2rem;
height: 2rem;
font-weight: 500;
font-size: 0.625rem;
color: var(--primary-button-color);
background-color: var(--primary-button-default);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 0.5rem;
cursor: pointer;
}
video {
max-width: 100%;
max-height: 100%;
border-radius: 0.75rem;
}
</style>

View File

@ -17,10 +17,9 @@
import { Photo } from '@hcengineering/attachment' import { Photo } from '@hcengineering/attachment'
import { Class, Doc, Ref, Space } from '@hcengineering/core' import { Class, Doc, Ref, Space } from '@hcengineering/core'
import { setPlatformStatus, unknownError } from '@hcengineering/platform' import { setPlatformStatus, unknownError } from '@hcengineering/platform'
import { createQuery, getClient, getFileUrl, PDFViewer } from '@hcengineering/presentation' import { FilePreviewPopup, createQuery, getClient, getFileUrl, uploadFile } from '@hcengineering/presentation'
import { Button, IconAdd, Label, showPopup, Spinner } from '@hcengineering/ui' import { Button, IconAdd, Label, showPopup, Spinner } from '@hcengineering/ui'
import attachment from '../plugin' import attachment from '../plugin'
import { uploadFile } from '../utils'
import UploadDuo from './icons/UploadDuo.svelte' import UploadDuo from './icons/UploadDuo.svelte'
export let objectId: Ref<Doc> export let objectId: Ref<Doc>
@ -88,7 +87,7 @@
el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' }) el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' })
if (item !== undefined) { if (item !== undefined) {
showPopup( showPopup(
PDFViewer, FilePreviewPopup,
{ file: item.file, name: item.name, contentType: item.type }, { file: item.file, name: item.name, contentType: item.type },
item.type.startsWith('image/') ? 'centered' : 'float' item.type.startsWith('image/') ? 'centered' : 'float'
) )

View File

@ -17,7 +17,7 @@ import attachment, { type Attachment } from '@hcengineering/attachment'
import { type ObjQueryType, SortingOrder, type SortingQuery, type Markup } from '@hcengineering/core' import { type ObjQueryType, SortingOrder, type SortingQuery, type Markup } from '@hcengineering/core'
import { type IntlString, type Resources } from '@hcengineering/platform' import { type IntlString, type Resources } from '@hcengineering/platform'
import preference from '@hcengineering/preference' import preference from '@hcengineering/preference'
import { getClient, PDFViewer } from '@hcengineering/presentation' import { PDFViewer, deleteFile, getClient, uploadFile } from '@hcengineering/presentation'
import activity, { type ActivityMessage, type DocUpdateMessage } from '@hcengineering/activity' import activity, { type ActivityMessage, type DocUpdateMessage } from '@hcengineering/activity'
import TxAttachmentCreate from './components/activity/TxAttachmentCreate.svelte' import TxAttachmentCreate from './components/activity/TxAttachmentCreate.svelte'
@ -42,8 +42,6 @@ import IconAttachment from './components/icons/Attachment.svelte'
import AttachmentPreview from './components/AttachmentPreview.svelte' import AttachmentPreview from './components/AttachmentPreview.svelte'
import AttachmentsUpdatedMessage from './components/activity/AttachmentsUpdatedMessage.svelte' import AttachmentsUpdatedMessage from './components/activity/AttachmentsUpdatedMessage.svelte'
import AttachmentsTooltip from './components/AttachmentsTooltip.svelte' import AttachmentsTooltip from './components/AttachmentsTooltip.svelte'
import MediaViewer from './components/MediaViewer.svelte'
import { deleteFile, uploadFile } from './utils'
export * from './types' export * from './types'
@ -260,8 +258,7 @@ export default async (): Promise<Resources> => ({
Attachments, Attachments,
FileBrowser, FileBrowser,
Photos, Photos,
PDFViewer, PDFViewer
MediaViewer
}, },
activity: { activity: {
TxAttachmentCreate, TxAttachmentCreate,

View File

@ -15,7 +15,7 @@
// //
import attachment, { attachmentId } from '@hcengineering/attachment' import attachment, { attachmentId } from '@hcengineering/attachment'
import type { IntlString, StatusCode } from '@hcengineering/platform' import type { IntlString } from '@hcengineering/platform'
import { mergeIds } from '@hcengineering/platform' import { mergeIds } from '@hcengineering/platform'
import { type ViewAction } from '@hcengineering/view' import { type ViewAction } from '@hcengineering/view'
@ -42,9 +42,6 @@ export default mergeIds(attachmentId, attachment, {
RemoveAttachmentFromSaved: '' as IntlString, RemoveAttachmentFromSaved: '' as IntlString,
Pinned: '' as IntlString Pinned: '' as IntlString
}, },
status: {
FileTooLarge: '' as StatusCode
},
actionImpl: { actionImpl: {
AddAttachmentToSaved: '' as ViewAction, AddAttachmentToSaved: '' as ViewAction,
DeleteAttachmentFromSaved: '' as ViewAction, DeleteAttachmentFromSaved: '' as ViewAction,

View File

@ -1,6 +1,6 @@
// //
// Copyright © 2020, 2021 Anticrm Platform Contributors. // Copyright © 2020, 2021 Anticrm Platform Contributors.
// Copyright © 2021 Hardcore Engineering Inc. // Copyright © 2021, 2024 Hardcore Engineering Inc.
// //
// Licensed under the Eclipse Public License, Version 2.0 (the "License"); // Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may // you may not use this file except in compliance with the License. You may
@ -14,79 +14,13 @@
// limitations under the License. // limitations under the License.
// //
import { writable } from 'svelte/store' import { type Attachment } from '@hcengineering/attachment'
import attachments, { import { type Class, type Data, type Doc, type Ref, type Space, type TxOperations as Client } from '@hcengineering/core'
type AttachmentPreviewExtension, import { getFileMetadata, uploadFile } from '@hcengineering/presentation'
type Attachment, import { setPlatformStatus, unknownError } from '@hcengineering/platform'
type AttachmentMetadata
} from '@hcengineering/attachment'
import {
type Class,
concatLink,
type Data,
type Doc,
type Ref,
type Space,
type TxOperations as Client
} from '@hcengineering/core'
import presentation, { createQuery, getFileUrl, getImageSize } from '@hcengineering/presentation'
import {
PlatformError,
Severity,
Status,
getMetadata,
getResource,
setPlatformStatus,
unknownError
} from '@hcengineering/platform'
import attachment from './plugin' import attachment from './plugin'
export async function uploadFile (file: File): Promise<string> {
const uploadUrl = getMetadata(presentation.metadata.UploadURL)
if (uploadUrl === undefined) {
throw Error('UploadURL is not defined')
}
const data = new FormData()
data.append('file', file)
const resp = await fetch(uploadUrl, {
method: 'POST',
headers: {
Authorization: 'Bearer ' + (getMetadata(presentation.metadata.Token) as string)
},
body: data
})
if (resp.status !== 200) {
if (resp.status === 413) {
throw new PlatformError(new Status(Severity.ERROR, attachment.status.FileTooLarge, {}))
} else {
throw Error(`Failed to upload file: ${resp.statusText}`)
}
}
return await resp.text()
}
export async function deleteFile (id: string): Promise<void> {
const uploadUrl = getMetadata(presentation.metadata.UploadURL) ?? ''
const url = concatLink(uploadUrl, `?file=${id}`)
const resp = await fetch(url, {
method: 'DELETE',
headers: {
Authorization: 'Bearer ' + (getMetadata(presentation.metadata.Token) as string)
}
})
if (resp.status !== 200) {
throw new Error('Failed to delete file')
}
}
export async function createAttachments ( export async function createAttachments (
client: Client, client: Client,
list: FileList, list: FileList,
@ -100,7 +34,7 @@ export async function createAttachments (
const file = list.item(index) const file = list.item(index)
if (file !== null) { if (file !== null) {
const uuid = await uploadFile(file) const uuid = await uploadFile(file)
const metadata = await getAttachmentMetadata(file, uuid) const metadata = await getFileMetadata(file, uuid)
await client.addCollection(attachmentClass, space, objectId, objectClass, 'attachments', { await client.addCollection(attachmentClass, space, objectId, objectClass, 'attachments', {
...extraData, ...extraData,
@ -140,114 +74,3 @@ export function getType (type: string): 'image' | 'text' | 'json' | 'video' | 'a
return 'other' return 'other'
} }
export async function getAttachmentMetadata (file: File, uuid: string): Promise<AttachmentMetadata | undefined> {
const type = getType(file.type)
if (type === 'video') {
const size = await getVideoSize(uuid)
if (size === undefined) {
return undefined
}
return {
originalHeight: size.height,
originalWidth: size.width
}
}
if (type === 'image') {
const size = await getImageSize(file, getFileUrl(uuid, 'full'))
return {
originalHeight: size.height,
originalWidth: size.width,
pixelRatio: size.pixelRatio
}
}
return undefined
}
async function getVideoSize (uuid: string): Promise<{ width: number, height: number } | undefined> {
const promise = new Promise<{ width: number, height: number }>((resolve, reject) => {
const element = document.createElement('video')
element.onloadedmetadata = () => {
const height = element.videoHeight
const width = element.videoWidth
resolve({ height, width })
}
element.onerror = reject
element.src = getFileUrl(uuid, 'full')
})
return await promise
}
export const previewTypes = writable<AttachmentPreviewExtension[]>([])
const previewTypesQuery = createQuery(true)
previewTypesQuery.query(attachments.class.AttachmentPreviewExtension, {}, (result) => {
previewTypes.set(result)
})
function getPreviewTypeRegExp (type: string): RegExp {
return new RegExp(`^${type.replaceAll('/', '\\/').replaceAll('*', '.*')}$`)
}
/**
* @public
*/
export async function isOpenable (contentType: string, _previewTypes: AttachmentPreviewExtension[]): Promise<boolean> {
for (const previewType of _previewTypes) {
if (await isApplicableType(previewType, contentType)) {
return true
}
}
return false
}
async function isApplicableType (
{ contentType, availabilityChecker }: AttachmentPreviewExtension,
_contentType: string
): Promise<boolean> {
const checkAvailability = availabilityChecker !== undefined ? await getResource(availabilityChecker) : undefined
const isAvailable: boolean = checkAvailability === undefined || (await checkAvailability())
return (
isAvailable &&
(Array.isArray(contentType) ? contentType : [contentType]).some((type) =>
getPreviewTypeRegExp(type).test(_contentType)
)
)
}
function comparePreviewTypes (a: AttachmentPreviewExtension, b: AttachmentPreviewExtension): number {
if (a.order === undefined && b.order === undefined) {
return 0
} else if (a.order === undefined) {
return -1
} else if (b.order === undefined) {
return 1
} else {
return a.order - b.order
}
}
export async function getPreviewType (
contentType: string,
_previewTypes: AttachmentPreviewExtension[]
): Promise<AttachmentPreviewExtension | undefined> {
const applicableTypes: AttachmentPreviewExtension[] = []
for (const previewType of _previewTypes) {
if (await isApplicableType(previewType, contentType)) {
applicableTypes.push(previewType)
}
}
return applicableTypes.sort(comparePreviewTypes)[0]
}

View File

@ -18,8 +18,8 @@ import type { AttachedDoc, Class, Ref } from '@hcengineering/core'
import type { Asset, Plugin } from '@hcengineering/platform' import type { Asset, Plugin } from '@hcengineering/platform'
import { IntlString, plugin, Resource } from '@hcengineering/platform' import { IntlString, plugin, Resource } from '@hcengineering/platform'
import type { Preference } from '@hcengineering/preference' import type { Preference } from '@hcengineering/preference'
import { AnyComponent, ComponentExtensionId } from '@hcengineering/ui' import { type BlobMetadata } from '@hcengineering/presentation'
import { type ComponentPointExtension } from '@hcengineering/presentation' import { AnyComponent } from '@hcengineering/ui'
/** /**
* @public * @public
@ -41,24 +41,7 @@ export interface Attachment extends AttachedDoc {
/** /**
* @public * @public
*/ */
export type AttachmentMetadata = ImageMetadata | VideoMetadata export type AttachmentMetadata = BlobMetadata
/**
* @public
*/
export interface ImageMetadata {
originalWidth: number
originalHeight: number
pixelRatio: number
}
/**
* @public
*/
export interface VideoMetadata {
originalWidth: number
originalHeight: number
}
/** /**
* @public * @public
@ -72,16 +55,6 @@ export interface SavedAttachments extends Preference {
attachedTo: Ref<Attachment> attachedTo: Ref<Attachment>
} }
/**
* @public
*/
export interface AttachmentPreviewExtension extends ComponentPointExtension {
contentType: string | string[]
alignment?: string
// Extension is only available if this checker returns true
availabilityChecker?: Resource<() => Promise<boolean>>
}
/** /**
* @public * @public
*/ */
@ -93,8 +66,7 @@ export default plugin(attachmentId, {
Photos: '' as AnyComponent, Photos: '' as AnyComponent,
AttachmentsPresenter: '' as AnyComponent, AttachmentsPresenter: '' as AnyComponent,
FileBrowser: '' as AnyComponent, FileBrowser: '' as AnyComponent,
PDFViewer: '' as AnyComponent, PDFViewer: '' as AnyComponent
MediaViewer: '' as AnyComponent
}, },
icon: { icon: {
Attachment: '' as Asset, Attachment: '' as Asset,
@ -103,8 +75,7 @@ export default plugin(attachmentId, {
class: { class: {
Attachment: '' as Ref<Class<Attachment>>, Attachment: '' as Ref<Class<Attachment>>,
Photo: '' as Ref<Class<Photo>>, Photo: '' as Ref<Class<Photo>>,
SavedAttachments: '' as Ref<Class<SavedAttachments>>, SavedAttachments: '' as Ref<Class<SavedAttachments>>
AttachmentPreviewExtension: '' as Ref<Class<AttachmentPreviewExtension>>
}, },
helper: { helper: {
UploadFile: '' as Resource<(file: File) => Promise<string>>, UploadFile: '' as Resource<(file: File) => Promise<string>>,
@ -130,13 +101,5 @@ export default plugin(attachmentId, {
DeleteFile: '' as IntlString, DeleteFile: '' as IntlString,
Attachments: '' as IntlString, Attachments: '' as IntlString,
FileBrowser: '' as IntlString FileBrowser: '' as IntlString
},
previewExtension: {
Image: '' as Ref<AttachmentPreviewExtension>,
Media: '' as Ref<AttachmentPreviewExtension>,
PDF: '' as Ref<AttachmentPreviewExtension>
},
extension: {
AttachmentPreview: '' as ComponentExtensionId
} }
}) })

View File

@ -16,6 +16,7 @@
import attachment from '@hcengineering/attachment' import attachment from '@hcengineering/attachment'
import { AvatarType } from '@hcengineering/contact' import { AvatarType } from '@hcengineering/contact'
import { Asset, getResource } from '@hcengineering/platform' import { Asset, getResource } from '@hcengineering/platform'
import { uploadFile } from '@hcengineering/presentation'
import { import {
AnySvelteComponent, AnySvelteComponent,
IconSize, IconSize,

View File

@ -41,7 +41,6 @@
"@hcengineering/platform": "^0.6.9", "@hcengineering/platform": "^0.6.9",
"@hcengineering/analytics": "^0.6.0", "@hcengineering/analytics": "^0.6.0",
"@hcengineering/core": "^0.6.28", "@hcengineering/core": "^0.6.28",
"@hcengineering/attachment": "^0.6.9",
"@hcengineering/drive": "^0.6.0", "@hcengineering/drive": "^0.6.0",
"@hcengineering/contact-resources": "^0.6.0", "@hcengineering/contact-resources": "^0.6.0",
"@hcengineering/panel": "^0.6.15", "@hcengineering/panel": "^0.6.15",

View File

@ -15,28 +15,53 @@
// //
--> -->
<script lang="ts"> <script lang="ts">
import { WithLookup } from '@hcengineering/core'
import { File } from '@hcengineering/drive' import { File } from '@hcengineering/drive'
import { getEmbeddedLabel } from '@hcengineering/platform' import { getEmbeddedLabel } from '@hcengineering/platform'
import { Icon, tooltip } from '@hcengineering/ui' import { FilePreviewPopup } from '@hcengineering/presentation'
import { DocNavLink, ObjectMention } from '@hcengineering/view-resources' import { Icon, showPopup, tooltip } from '@hcengineering/ui'
import { ObjectPresenterType } from '@hcengineering/view' import { ObjectPresenterType } from '@hcengineering/view'
import { DocNavLink, ObjectMention } from '@hcengineering/view-resources'
import drive from '../plugin' import drive from '../plugin'
export let value: File export let value: WithLookup<File>
export let inline: boolean = false export let inline: boolean = false
export let disabled: boolean = false export let disabled: boolean = false
export let accent: boolean = false export let accent: boolean = false
export let noUnderline: boolean = false export let noUnderline: boolean = false
export let shouldShowAvatar = true export let shouldShowAvatar = true
export let type: ObjectPresenterType = 'link' export let type: ObjectPresenterType = 'link'
function handleClick (): void {
if (disabled) {
return
}
if (value.$lookup?.file === undefined) {
return
}
const blob = value.$lookup?.file
showPopup(
FilePreviewPopup,
{
file: blob._id,
contentType: blob.contentType,
name: value.name,
metadata: value.metadata
},
'float'
)
}
</script> </script>
{#if value} {#if value}
{#if inline} {#if inline}
<ObjectMention object={value} {disabled} {accent} {noUnderline} /> <ObjectMention object={value} {disabled} {accent} {noUnderline} />
{:else if type === 'link'} {:else if type === 'link'}
<DocNavLink {disabled} object={value} {accent} {noUnderline}> <DocNavLink object={value} onClick={handleClick} {disabled} {accent} {noUnderline}>
<div class="flex-presenter" use:tooltip={{ label: getEmbeddedLabel(value.name) }}> <div class="flex-presenter" use:tooltip={{ label: getEmbeddedLabel(value.name) }}>
{#if shouldShowAvatar} {#if shouldShowAvatar}
<div class="icon"> <div class="icon">

View File

@ -13,11 +13,10 @@
// limitations under the License. // limitations under the License.
// //
import attachment from '@hcengineering/attachment' import { type Class, type Doc, type Ref } from '@hcengineering/core'
import { type Blob, type Class, type Doc, type Ref } from '@hcengineering/core'
import drive, { type Drive, type Folder } from '@hcengineering/drive' import drive, { type Drive, type Folder } from '@hcengineering/drive'
import { getResource, setPlatformStatus, unknownError } from '@hcengineering/platform' import { setPlatformStatus, unknownError } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation' import { getClient, getFileMetadata, uploadFile } from '@hcengineering/presentation'
import { showPopup } from '@hcengineering/ui' import { showPopup } from '@hcengineering/ui'
import { openDoc } from '@hcengineering/view-resources' import { openDoc } from '@hcengineering/view-resources'
@ -68,14 +67,13 @@ export async function createFile (file: File, space: Ref<Drive>, parent: Folder
const client = getClient() const client = getClient()
try { try {
const uploadFile = await getResource(attachment.helper.UploadFile)
const uuid = await uploadFile(file) const uuid = await uploadFile(file)
// TODO obtain metadata const metadata = await getFileMetadata(file, uuid)
// const metadata = await getAttachmentMetadata(file, uuid)
await client.createDoc(drive.class.File, space, { await client.createDoc(drive.class.File, space, {
name: file.name, name: file.name,
file: uuid as Ref<Blob>, file: uuid,
metadata,
parent: parent?._id ?? drive.ids.Root, parent: parent?._id ?? drive.ids.Root,
path: parent !== undefined ? [parent._id, ...parent.path] : [] path: parent !== undefined ? [parent._id, ...parent.path] : []
}) })

View File

@ -41,6 +41,7 @@ export interface Folder extends Resource {
/** @public */ /** @public */
export interface File extends Resource { export interface File extends Resource {
file: Ref<Blob> file: Ref<Blob>
metadata?: Record<string, any>
parent: Ref<Folder> parent: Ref<Folder>
path: Ref<Folder>[] path: Ref<Folder>[]

View File

@ -15,7 +15,6 @@
<script lang="ts"> <script lang="ts">
import { Analytics } from '@hcengineering/analytics' import { Analytics } from '@hcengineering/analytics'
import attachment from '@hcengineering/attachment' import attachment from '@hcengineering/attachment'
import { deleteFile } from '@hcengineering/attachment-resources/src/utils'
import contact, { Channel, ChannelProvider, combineName, findContacts, Person } from '@hcengineering/contact' import contact, { Channel, ChannelProvider, combineName, findContacts, Person } from '@hcengineering/contact'
import { ChannelsDropdown, EditableAvatar, PersonPresenter } from '@hcengineering/contact-resources' import { ChannelsDropdown, EditableAvatar, PersonPresenter } from '@hcengineering/contact-resources'
import { import {
@ -33,6 +32,7 @@
} from '@hcengineering/core' } from '@hcengineering/core'
import { getMetadata, getResource, setPlatformStatus, unknownError } from '@hcengineering/platform' import { getMetadata, getResource, setPlatformStatus, unknownError } from '@hcengineering/platform'
import presentation, { import presentation, {
FilePreviewPopup,
Card, Card,
createQuery, createQuery,
DraftController, DraftController,
@ -41,7 +41,7 @@
KeyedAttribute, KeyedAttribute,
MessageBox, MessageBox,
MultipleDraftController, MultipleDraftController,
PDFViewer deleteFile
} from '@hcengineering/presentation' } from '@hcengineering/presentation'
import type { Candidate, CandidateDraft } from '@hcengineering/recruit' import type { Candidate, CandidateDraft } from '@hcengineering/recruit'
import { recognizeDocument } from '@hcengineering/rekoni' import { recognizeDocument } from '@hcengineering/rekoni'
@ -740,8 +740,12 @@
icon={FileIcon} icon={FileIcon}
on:click={() => { on:click={() => {
showPopup( showPopup(
PDFViewer, FilePreviewPopup,
{ file: object.resumeUuid, name: object.resumeName }, {
file: object.resumeUuid,
contentType: object.resumeType ?? 'application/pdf',
name: object.resumeName
},
object.resumeType?.startsWith('image/') ? 'centered' : 'float' object.resumeType?.startsWith('image/') ? 'centered' : 'float'
) )
}} }}

View File

@ -14,9 +14,8 @@
--> -->
<script lang="ts"> <script lang="ts">
import attachment, { Attachment } from '@hcengineering/attachment' import attachment, { Attachment } from '@hcengineering/attachment'
import { deleteFile } from '@hcengineering/attachment-resources/src/utils'
import core, { AttachedData, Doc, Ref, SortingOrder } from '@hcengineering/core' import core, { AttachedData, Doc, Ref, SortingOrder } from '@hcengineering/core'
import { DraftController, draftsStore, getClient } from '@hcengineering/presentation' import { DraftController, draftsStore, getClient, deleteFile } from '@hcengineering/presentation'
import tags from '@hcengineering/tags' import tags from '@hcengineering/tags'
import { makeRank } from '@hcengineering/task' import { makeRank } from '@hcengineering/task'
import { Component, Issue, IssueDraft, IssueParentInfo, Milestone, Project } from '@hcengineering/tracker' import { Component, Issue, IssueDraft, IssueParentInfo, Milestone, Project } from '@hcengineering/tracker'

View File

@ -0,0 +1,58 @@
//
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { type Blob, type Ref } from '@hcengineering/core'
import { type BlobMetadata, getFileUrl, getImageSize } from '@hcengineering/presentation'
export async function blobImageMetadata (file: File, blob: Ref<Blob>): Promise<BlobMetadata | undefined> {
const size = await getImageSize(file, getFileUrl(blob, 'full'))
return {
originalHeight: size.height,
originalWidth: size.width,
pixelRatio: size.pixelRatio
}
}
export async function blobVideoMetadata (file: File, blob: Ref<Blob>): Promise<BlobMetadata | undefined> {
const size = await getVideoSize(blob)
if (size === undefined) {
return undefined
}
return {
originalHeight: size.height,
originalWidth: size.width
}
}
async function getVideoSize (uuid: string): Promise<{ width: number, height: number } | undefined> {
const promise = new Promise<{ width: number, height: number }>((resolve, reject) => {
const element = document.createElement('video')
element.onloadedmetadata = () => {
const height = element.videoHeight
const width = element.videoWidth
resolve({ height, width })
}
element.onerror = reject
element.src = getFileUrl(uuid, 'full')
})
return await promise
}

View File

@ -0,0 +1,43 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
export let size: 'small' | 'medium' | 'large'
const fill: string = 'currentColor'
</script>
<svg class="svg-{size}" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<g {fill}>
<path
d="M39.806,72.858h-8.915c-2.176,0-3.94-1.764-3.94-3.94V31.119c0-2.176,1.764-3.94,3.94-3.94h8.915 c2.176,0,3.94,1.764,3.94,3.94v37.799C43.746,71.094,41.982,72.858,39.806,72.858z"
/>
<path
d="M68.109,72.821h-8.915c-2.176,0-3.94-1.764-3.94-3.94V31.082c0-2.176,1.764-3.94,3.94-3.94h8.915 c2.176,0,3.94,1.764,3.94,3.94v37.799C72.049,71.057,70.285,72.821,68.109,72.821z"
/>
<path
d="M40.489,27.248c0.769,0.719,1.257,1.735,1.257,2.871v37.799c0,2.176-1.764,3.94-3.94,3.94h-8.915 c-0.234,0-0.46-0.03-0.683-0.069c0.704,0.658,1.643,1.069,2.683,1.069h8.915c2.176,0,3.94-1.764,3.94-3.94V31.119 C43.746,29.177,42.338,27.573,40.489,27.248z"
/>
<path
d="M68.792,27.211c0.769,0.719,1.257,1.735,1.257,2.871v37.799c0,2.176-1.764,3.94-3.94,3.94h-8.915 c-0.234,0-0.46-0.03-0.683-0.069c0.704,0.658,1.643,1.069,2.683,1.069h8.915c2.176,0,3.94-1.764,3.94-3.94V31.082 C72.049,29.14,70.641,27.535,68.792,27.211z"
/>
<path
d="M39.806,72.858h-8.915c-2.176,0-3.94-1.764-3.94-3.94V31.119 c0-2.176,1.764-3.94,3.94-3.94h8.915c2.176,0,3.94,1.764,3.94,3.94v37.799C43.746,71.094,41.982,72.858,39.806,72.858z"
style="fill:none;stroke:#000000;stroke-miterlimit:10;"
/>
<path
d="M68.109,72.821h-8.915c-2.176,0-3.94-1.764-3.94-3.94V31.082 c0-2.176,1.764-3.94,3.94-3.94h8.915c2.176,0,3.94,1.764,3.94,3.94v37.799C72.049,71.057,70.285,72.821,68.109,72.821z"
style="fill:none;stroke:#000000;stroke-miterlimit:10;"
/>
</g>
</svg>

View File

@ -0,0 +1,67 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { type Blob, type Ref } from '@hcengineering/core'
import { CircleButton, Progress } from '@hcengineering/ui'
import Play from '../icons/Play.svelte'
import Pause from '../icons/Pause.svelte'
import { getFileUrl } from '@hcengineering/presentation'
export let value: Ref<Blob>
export let name: string
export let contentType: string
export let fullSize = false
let time = 0
let duration = Number.POSITIVE_INFINITY
let paused = true
function handleClick (): void {
paused = !paused
}
$: icon = !paused ? Pause : Play
</script>
<div class="container flex-between" class:fullSize>
<CircleButton size="x-large" on:click={handleClick} {icon} />
<div class="w-full ml-4">
<Progress
value={time}
max={Number.isFinite(duration) ? duration : 100}
editable
on:change={(e) => (time = e.detail)}
/>
</div>
</div>
<audio bind:duration bind:currentTime={time} bind:paused>
<source src={getFileUrl(value, 'full', name)} type={contentType} />
</audio>
<style lang="scss">
.container {
padding: 0.5rem;
width: 20rem;
background-color: var(--accent-bg-color);
border: 1px solid var(--divider-color);
border-radius: 0.75rem;
&.fullSize {
width: 100%;
}
}
</style>

View File

@ -0,0 +1,41 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { type Blob, type Ref } from '@hcengineering/core'
import { type BlobMetadata, getFileUrl } from '@hcengineering/presentation'
import AudioPlayer from './AudioPlayer.svelte'
export let value: Ref<Blob>
export let name: string
export let contentType: string
export let metadata: BlobMetadata | undefined
$: src = value === undefined ? '' : getFileUrl(value, 'full', name)
</script>
{#if src}
<AudioPlayer {value} {name} {contentType} fullSize={true} />
{/if}
<style lang="scss">
.img-fit {
margin: 0 auto;
width: fit-content;
height: fit-content;
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
</style>

View File

@ -0,0 +1,40 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { type Blob, type Ref } from '@hcengineering/core'
import { type BlobMetadata, getFileUrl } from '@hcengineering/presentation'
export let value: Ref<Blob>
export let name: string
export let contentType: string
export let metadata: BlobMetadata | undefined
$: src = value === undefined ? '' : getFileUrl(value, 'full', name)
</script>
{#if src}
<img class="w-full h-full img-fit" {src} alt="" />
{/if}
<style lang="scss">
.img-fit {
margin: 0 auto;
width: fit-content;
height: fit-content;
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
</style>

View File

@ -0,0 +1,35 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { type Blob, type Ref } from '@hcengineering/core'
import { type BlobMetadata, getFileUrl } from '@hcengineering/presentation'
export let value: Ref<Blob>
export let name: string
export let contentType: string
export let metadata: BlobMetadata | undefined
$: src = value === undefined ? '' : getFileUrl(value, 'full', name)
</script>
{#if src}
<iframe src={src + '#view=FitH&navpanes=0'} class="w-full h-full" title={name} />
{/if}
<style lang="scss">
iframe {
border: none;
}
</style>

View File

@ -0,0 +1,32 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { type Blob, type Ref } from '@hcengineering/core'
import { type BlobMetadata, getFileUrl } from '@hcengineering/presentation'
export let value: Ref<Blob>
export let name: string
export let contentType: string
export let metadata: BlobMetadata | undefined
$: src = value === undefined ? '' : getFileUrl(value, 'full', name)
</script>
{#if src}
<video controls preload={'auto'}>
<source {src} />
<track kind="captions" label={name} />
</video>
{/if}

View File

@ -93,6 +93,12 @@ import TreeItem from './components/navigator/TreeItem.svelte'
import TreeNode from './components/navigator/TreeNode.svelte' import TreeNode from './components/navigator/TreeNode.svelte'
import StatusPresenter from './components/status/StatusPresenter.svelte' import StatusPresenter from './components/status/StatusPresenter.svelte'
import StatusRefPresenter from './components/status/StatusRefPresenter.svelte' import StatusRefPresenter from './components/status/StatusRefPresenter.svelte'
import AudioViewer from './components/viewer/AudioViewer.svelte'
import ImageViewer from './components/viewer/ImageViewer.svelte'
import VideoViewer from './components/viewer/VideoViewer.svelte'
import PDFViewer from './components/viewer/PDFViewer.svelte'
import { blobImageMetadata, blobVideoMetadata } from './blob'
import { import {
afterResult, afterResult,
@ -281,7 +287,11 @@ export default async (): Promise<Resources> => ({
StringFilterPresenter, StringFilterPresenter,
AttachedDocPanel, AttachedDocPanel,
ObjectMention, ObjectMention,
SearchSelector SearchSelector,
AudioViewer,
ImageViewer,
VideoViewer,
PDFViewer
}, },
popup: { popup: {
PositionElementAlignment PositionElementAlignment
@ -317,6 +327,8 @@ export default async (): Promise<Resources> => ({
CanArchiveSpace: canArchiveSpace, CanArchiveSpace: canArchiveSpace,
CanDeleteSpace: canDeleteSpace, CanDeleteSpace: canDeleteSpace,
CanJoinSpace: canJoinSpace, CanJoinSpace: canJoinSpace,
CanLeaveSpace: canLeaveSpace CanLeaveSpace: canLeaveSpace,
BlobImageMetadata: blobImageMetadata,
BlobVideoMetadata: blobVideoMetadata
} }
}) })