mirror of
https://github.com/hcengineering/platform.git
synced 2025-06-01 21:31:04 +00:00
452 lines
12 KiB
Svelte
452 lines
12 KiB
Svelte
<!--
|
|
// Copyright © 2022, 2025 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 { Attachment, BlobMetadata } from '@hcengineering/attachment'
|
|
import {
|
|
Account,
|
|
Class,
|
|
Doc,
|
|
generateId,
|
|
Markup,
|
|
Ref,
|
|
Space,
|
|
toIdMap,
|
|
type Blob,
|
|
TxOperations
|
|
} from '@hcengineering/core'
|
|
import { IntlString, setPlatformStatus, unknownError } from '@hcengineering/platform'
|
|
import {
|
|
createQuery,
|
|
deleteFile,
|
|
DraftController,
|
|
draftsStore,
|
|
FileOrBlob,
|
|
getClient,
|
|
getFileMetadata,
|
|
uploadFile
|
|
} from '@hcengineering/presentation'
|
|
import { EmptyMarkup } from '@hcengineering/text'
|
|
import textEditor, { type RefAction } from '@hcengineering/text-editor'
|
|
import { AttachIcon, StyledTextBox } from '@hcengineering/text-editor-resources'
|
|
import { ButtonSize } from '@hcengineering/ui'
|
|
import { type FileUploadCallbackParams, uploadFiles } from '@hcengineering/uploader'
|
|
import { createEventDispatcher, onDestroy } from 'svelte'
|
|
|
|
import attachment from '../plugin'
|
|
import AttachmentsGrid from './AttachmentsGrid.svelte'
|
|
|
|
export let objectId: Ref<Doc> | undefined = undefined
|
|
export let space: Ref<Space> | undefined = undefined
|
|
export let _class: Ref<Class<Doc>> | undefined = undefined
|
|
export let content: Markup = EmptyMarkup
|
|
export let placeholder: IntlString | undefined = undefined
|
|
export let alwaysEdit = false
|
|
export let showButtons = false
|
|
export let kind: 'normal' | 'emphasized' | 'indented' = 'normal'
|
|
export let buttonSize: ButtonSize = 'medium'
|
|
export let maxHeight: 'max' | 'card' | 'limited' | string = 'max'
|
|
export let focusable: boolean = false
|
|
export let refContainer: HTMLElement | undefined = undefined
|
|
export let shouldSaveDraft: boolean = false
|
|
export let useAttachmentPreview = false
|
|
export let focusIndex: number | undefined = -1
|
|
export let enableAttachments: boolean = true
|
|
export let enableBackReferences: boolean = false
|
|
export let isScrollable = true
|
|
|
|
export let useDirectAttachDelete = false
|
|
export let boundary: HTMLElement | undefined = undefined
|
|
|
|
let progress = false
|
|
|
|
let draftKey = objectId != null ? `${objectId as string}_attachments` : undefined
|
|
$: draftKey = objectId ? `${objectId}_attachments` : undefined
|
|
|
|
const dispatch = createEventDispatcher()
|
|
|
|
export function focus (): void {
|
|
refInput.focus()
|
|
}
|
|
export function isFocused (): boolean {
|
|
return refInput.isFocused()
|
|
}
|
|
export function isEditable (): boolean {
|
|
return refInput.isEditable()
|
|
}
|
|
export function setEditable (editable: boolean): void {
|
|
refInput.setEditable(editable)
|
|
}
|
|
export function setContent (data: string): void {
|
|
refInput.setContent(data)
|
|
}
|
|
export function handleAttach (): void {
|
|
inputFile.click()
|
|
}
|
|
|
|
export function submit (): void {
|
|
refInput.submit()
|
|
}
|
|
let refInput: StyledTextBox
|
|
let extraActions: RefAction[] = []
|
|
|
|
let inputFile: HTMLInputElement
|
|
let saved = false
|
|
|
|
const client = getClient()
|
|
const query = createQuery()
|
|
let attachments: Map<Ref<Attachment>, Attachment> = new Map<Ref<Attachment>, Attachment>()
|
|
let originalAttachments: Set<Ref<Attachment>> = new Set<Ref<Attachment>>()
|
|
const newAttachments: Set<Ref<Attachment>> = new Set<Ref<Attachment>>()
|
|
const removedAttachments: Set<Attachment> = new Set<Attachment>()
|
|
|
|
$: draftKey && updateAttachments(objectId, $draftsStore[draftKey])
|
|
|
|
async function updateAttachments (
|
|
objectId: Ref<Doc> | undefined,
|
|
draftAttachments: Record<Ref<Attachment>, Attachment> | undefined
|
|
) {
|
|
if (draftAttachments && shouldSaveDraft) {
|
|
query.unsubscribe()
|
|
attachments.clear()
|
|
newAttachments.clear()
|
|
Object.entries(draftAttachments).map((file) => {
|
|
return attachments.set(file[0] as Ref<Attachment>, file[1])
|
|
})
|
|
Object.entries(draftAttachments).map((file) => {
|
|
return newAttachments.add(file[0] as Ref<Attachment>)
|
|
})
|
|
originalAttachments.clear()
|
|
removedAttachments.clear()
|
|
attachments = attachments
|
|
} else if (objectId) {
|
|
query.query(
|
|
attachment.class.Attachment,
|
|
{
|
|
attachedTo: objectId
|
|
},
|
|
(res) => {
|
|
originalAttachments = new Set(res.map((p) => p._id))
|
|
attachments = toIdMap(res)
|
|
dispatch('attach', { action: 'saved', value: attachments.size })
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
async function saveDraft () {
|
|
if (draftKey && shouldSaveDraft) {
|
|
const draftAttachments = Object.fromEntries(attachments)
|
|
DraftController.save(draftKey, draftAttachments)
|
|
}
|
|
}
|
|
|
|
async function attachFile (file: File): Promise<{ file: Ref<Blob>, type: string } | undefined> {
|
|
try {
|
|
const uuid = await uploadFile(file)
|
|
const metadata = await getFileMetadata(file, uuid)
|
|
await createAttachment(uuid, file.name, file, metadata)
|
|
return { file: uuid, type: file.type }
|
|
} catch (err: any) {
|
|
await setPlatformStatus(unknownError(err))
|
|
}
|
|
}
|
|
|
|
async function onFileUploaded ({ uuid, name, file, metadata }: FileUploadCallbackParams): Promise<void> {
|
|
await createAttachment(uuid, name, file, metadata)
|
|
}
|
|
|
|
async function createAttachment (
|
|
uuid: Ref<Blob>,
|
|
name: string,
|
|
file: FileOrBlob,
|
|
metadata: BlobMetadata | undefined
|
|
): Promise<void> {
|
|
if (space === undefined || objectId === undefined || _class === undefined) return
|
|
try {
|
|
const _id: Ref<Attachment> = generateId()
|
|
|
|
attachments.set(_id, {
|
|
_id,
|
|
_class: attachment.class.Attachment,
|
|
collection: 'attachments',
|
|
modifiedOn: 0,
|
|
modifiedBy: '' as Ref<Account>,
|
|
space,
|
|
attachedTo: objectId,
|
|
attachedToClass: _class,
|
|
name,
|
|
file: uuid,
|
|
type: file.type,
|
|
size: file.size,
|
|
lastModified: file instanceof File ? file.lastModified : Date.now(),
|
|
metadata
|
|
})
|
|
|
|
newAttachments.add(_id)
|
|
attachments = attachments
|
|
saved = false
|
|
saveDraft()
|
|
dispatch('attach', { action: 'saved', value: attachments.size })
|
|
dispatch('attached', _id)
|
|
|
|
if (useDirectAttachDelete) {
|
|
saveNewAttachment(_id)
|
|
}
|
|
} catch (err: any) {
|
|
setPlatformStatus(unknownError(err))
|
|
}
|
|
}
|
|
|
|
async function saveAttachment (doc: Attachment, objectId: Ref<Doc> | undefined, op?: TxOperations): Promise<void> {
|
|
if (space === undefined || objectId === undefined || _class === undefined) return
|
|
newAttachments.delete(doc._id)
|
|
await (op ?? client).addCollection(
|
|
attachment.class.Attachment,
|
|
space,
|
|
objectId,
|
|
_class,
|
|
'attachments',
|
|
doc,
|
|
doc._id
|
|
)
|
|
}
|
|
|
|
async function fileSelected (): Promise<void> {
|
|
progress = true
|
|
const list = inputFile.files
|
|
if (list === null || list.length === 0) return
|
|
await uploadFiles(list, { onFileUploaded })
|
|
inputFile.value = ''
|
|
progress = false
|
|
}
|
|
|
|
export async function fileDrop (e: DragEvent): Promise<void> {
|
|
progress = true
|
|
const list = e.dataTransfer?.files
|
|
if (list === undefined || list.length === 0) return
|
|
await uploadFiles(list, { onFileUploaded })
|
|
progress = false
|
|
}
|
|
|
|
async function removeAttachment (attachment: Attachment): Promise<void> {
|
|
if (useDirectAttachDelete) {
|
|
progressItems.push(attachment._id)
|
|
progressItems = progressItems
|
|
await deleteAttachment(attachment)
|
|
}
|
|
removedAttachments.add(attachment)
|
|
attachments.delete(attachment._id)
|
|
attachments = attachments
|
|
refInput.removeAttachment(attachment.file)
|
|
saveDraft()
|
|
dispatch('detached', attachment._id)
|
|
|
|
progressItems = progressItems.filter((it) => it !== attachment._id)
|
|
}
|
|
|
|
async function deleteAttachment (attachment: Attachment): Promise<void> {
|
|
removedAttachments.delete(attachment)
|
|
if (originalAttachments.has(attachment._id)) {
|
|
await client.removeCollection(
|
|
attachment._class,
|
|
attachment.space,
|
|
attachment._id,
|
|
attachment.attachedTo,
|
|
attachment.attachedToClass,
|
|
'attachments'
|
|
)
|
|
dispatch('detached', attachment._id)
|
|
} else {
|
|
await deleteFile(attachment.file)
|
|
}
|
|
}
|
|
|
|
onDestroy(() => {
|
|
if (!saved && !shouldSaveDraft) {
|
|
newAttachments.forEach((p) => {
|
|
const attachment = attachments.get(p)
|
|
if (attachment !== undefined) {
|
|
void deleteAttachment(attachment)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
export function removeDraft (removeFiles: boolean) {
|
|
if (draftKey) {
|
|
DraftController.remove(draftKey)
|
|
}
|
|
if (removeFiles) {
|
|
newAttachments.forEach((p) => {
|
|
const attachment = attachments.get(p)
|
|
if (attachment !== undefined) {
|
|
deleteFile(attachment.file)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
export async function saveNewAttachment (_id: Ref<Attachment>): Promise<void> {
|
|
const attachment = attachments.get(_id)
|
|
if (attachment !== undefined) {
|
|
await saveAttachment(attachment, objectId)
|
|
}
|
|
}
|
|
|
|
export async function removeAttachmentById (_id: Ref<Attachment>): Promise<void> {
|
|
const attachment = attachments.get(_id)
|
|
if (attachment !== undefined) {
|
|
await removeAttachment(attachment)
|
|
}
|
|
}
|
|
|
|
export async function createAttachments (_id: Ref<Doc> | undefined = objectId, op?: TxOperations): Promise<void> {
|
|
if (saved) {
|
|
return
|
|
}
|
|
saved = true
|
|
const promises: Promise<any>[] = []
|
|
newAttachments.forEach((p) => {
|
|
const attachment = attachments.get(p)
|
|
if (attachment !== undefined) {
|
|
promises.push(saveAttachment(attachment, _id, op))
|
|
}
|
|
})
|
|
removedAttachments.forEach((p) => {
|
|
promises.push(deleteAttachment(p))
|
|
})
|
|
await Promise.all(promises)
|
|
removeDraft(false)
|
|
newAttachments.clear()
|
|
removedAttachments.clear()
|
|
}
|
|
|
|
function isAllowedPaste (evt: ClipboardEvent) {
|
|
let t: HTMLElement | null = evt.target as HTMLElement
|
|
|
|
if (!refContainer) {
|
|
return true
|
|
}
|
|
|
|
while (t != null) {
|
|
t = t.parentElement
|
|
if (t === refContainer) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
export async function pasteAction (evt: ClipboardEvent): Promise<void> {
|
|
if (!isAllowedPaste(evt)) {
|
|
return
|
|
}
|
|
|
|
const items = evt.clipboardData?.items ?? []
|
|
const files: File[] = []
|
|
for (const index in items) {
|
|
const item = items[index]
|
|
if (item.kind === 'file') {
|
|
const blob = item.getAsFile()
|
|
if (blob !== null) {
|
|
files.push(blob)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (files.length > 0) {
|
|
await uploadFiles(files, { onFileUploaded })
|
|
}
|
|
}
|
|
|
|
$: dispatch('attachments', {
|
|
size: attachments.size,
|
|
values: attachments.size === 0 ? true : attachments
|
|
})
|
|
|
|
$: if (enableAttachments) {
|
|
extraActions = [
|
|
{
|
|
label: textEditor.string.Attach,
|
|
icon: AttachIcon,
|
|
action: handleAttach,
|
|
order: 1001
|
|
}
|
|
]
|
|
} else {
|
|
extraActions = []
|
|
}
|
|
|
|
let progressItems: Ref<Doc>[] = []
|
|
</script>
|
|
|
|
<input
|
|
bind:this={inputFile}
|
|
multiple
|
|
type="file"
|
|
name="file"
|
|
id="file"
|
|
style="display: none"
|
|
disabled={inputFile == null}
|
|
on:change={fileSelected}
|
|
/>
|
|
|
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
|
<div
|
|
class="flex-col clear-mins"
|
|
on:paste={(ev) => pasteAction(ev)}
|
|
on:dragover|preventDefault={() => {}}
|
|
on:dragleave={() => {}}
|
|
on:drop|preventDefault|stopPropagation={(ev) => {
|
|
fileDrop(ev)
|
|
}}
|
|
>
|
|
<StyledTextBox
|
|
{focusIndex}
|
|
bind:this={refInput}
|
|
bind:content
|
|
{placeholder}
|
|
{alwaysEdit}
|
|
{showButtons}
|
|
{buttonSize}
|
|
{maxHeight}
|
|
{focusable}
|
|
{kind}
|
|
{enableBackReferences}
|
|
{isScrollable}
|
|
{boundary}
|
|
{extraActions}
|
|
on:changeSize
|
|
on:changeContent
|
|
on:blur
|
|
on:focus
|
|
on:open-document
|
|
{attachFile}
|
|
/>
|
|
{#if attachments.size > 0 && enableAttachments}
|
|
<AttachmentsGrid
|
|
attachments={Array.from(attachments.values())}
|
|
{progress}
|
|
{useAttachmentPreview}
|
|
on:remove={async (evt) => {
|
|
if (evt.detail !== undefined) {
|
|
await removeAttachment(evt.detail)
|
|
}
|
|
}}
|
|
/>
|
|
{/if}
|
|
</div>
|