Files
2025-12-04 15:48:34 +07:00

407 lines
12 KiB
Svelte
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
// Copyright © 2023 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 ControlledDocument,
type DocumentMeta,
type DocumentSpace,
type DocumentSpaceType,
type Project,
type ProjectDocument,
ProjectDocumentTree,
getDocumentName
} from '@hcengineering/controlled-documents'
import { type Doc, type Ref, type Space, WithLookup } from '@hcengineering/core'
import { getResource } from '@hcengineering/platform'
import { createQuery, getClient } from '@hcengineering/presentation'
import {
type Action,
IconEdit,
Label,
closeTooltip,
getPlatformColorForTextDef,
navigate,
themeStore
} from '@hcengineering/ui'
import { ActionGroup } from '@hcengineering/view'
import { TreeItem, TreeNode, getActions as getContributedActions } from '@hcengineering/view-resources'
import { getDocumentIdFromFragment, getProjectDocumentLink } from '../../navigation'
import {
canCreateChildDocument,
canCreateChildFolder,
createDocument,
createDocumentHierarchyQuery,
createFolder,
getLatestProjectId,
isEditableProject,
moveDocument,
moveDocumentAfter,
moveDocumentBefore
} from '../../utils'
import ProjectSelector from '../project/ProjectSelector.svelte'
import DocHierarchyLevel from './DocHierarchyLevel.svelte'
import documents from '../../plugin'
import DropArea from './DropArea.svelte'
import DropMarker from './DropMarker.svelte'
export let space: DocumentSpace
export let currentSpace: Ref<Space> | undefined
export let currentFragment: string | undefined
export let getActions: (space: Space) => Promise<Action[]> = async () => []
export let deselect: boolean = false
export let forciblyСollapsed: boolean = false
const client = getClient()
let spaceType: DocumentSpaceType | undefined
let pressed: boolean = false
const spaceTypeQuery = createQuery()
$: spaceTypeQuery.query(documents.class.DocumentSpaceType, { _id: space.type }, (result) => {
;[spaceType] = result
})
$: selected = getDocumentIdFromFragment(currentFragment ?? '')
let project: Ref<Project> = documents.ids.NoProject
$: void selectProject(space)
let tree = new ProjectDocumentTree()
const query = createDocumentHierarchyQuery()
$: if (document !== undefined && project !== undefined) {
query.query(space._id, project, (data) => {
tree = data
})
}
let selectedControlledDoc: ControlledDocument | undefined = undefined
$: if (selected !== undefined) {
void client
.findOne(
documents.class.ProjectDocument,
{ _id: selected },
{
lookup: {
document: documents.class.ControlledDocument
}
}
)
.then((result) => {
if (result !== undefined) {
selectedControlledDoc = result.$lookup?.document as ControlledDocument
} else {
// There's some issue with resolving which needs to be fixed later
void client
.findOne(documents.class.ControlledDocument, { _id: selected as unknown as Ref<ControlledDocument> })
.then((result) => {
selectedControlledDoc = result
})
}
})
} else {
selectedControlledDoc = undefined
}
async function selectProject (space: DocumentSpace): Promise<void> {
project = (await getLatestProjectId(space._id, true)) ?? documents.ids.NoProject
}
function handleDocumentSelected (doc: WithLookup<ProjectDocument>): void {
if (doc.$lookup?.document !== undefined) {
const loc = getProjectDocumentLink(doc.$lookup?.document, doc.project)
navigate(loc)
}
}
async function getSpaceActions (space: DocumentSpace): Promise<Action[]> {
const actions = await getActions(space)
if (
spaceType?.projects === true &&
(await isEditableProject(project)) &&
(await canCreateChildDocument(space, true))
) {
actions.push({
icon: documents.icon.NewDocument,
label: documents.string.CreateDocument,
group: 'create',
action: async () => {
await createDocument(space)
}
})
}
if (
spaceType?.projects === true &&
(await isEditableProject(project)) &&
(await canCreateChildFolder(space, true))
) {
actions.push({
icon: documents.icon.Folder,
label: documents.string.CreateFolder,
group: 'create',
action: async () => {
await createFolder(space)
}
})
}
return orderActions(actions)
}
async function getDocumentActions (obj: Doc): Promise<Action[]> {
const result: Action[] = []
const extraActions = await getContributedActions(client, obj)
for (const act of extraActions) {
result.push({
icon: act.icon ?? IconEdit,
label: act.label,
group: act.context.group,
action: async (ctx: any, evt: Event) => {
const impl = await getResource(act.action)
await impl(obj, evt, act.actionProps)
}
})
}
return orderActions(result)
}
function orderActions (actions: Action[]): Action[] {
const order: Record<ActionGroup, number> = {
create: 1,
edit: 2,
copy: 3,
associate: 4,
tools: 5,
other: 6,
remove: 7
}
actions.sort((a, b) => order[(a.group as ActionGroup) ?? 'other'] - order[(b.group as ActionGroup) ?? 'other'])
return actions
}
let parent: HTMLElement
let draggedItem: Ref<DocumentMeta> | undefined = undefined
let draggedOver: Ref<DocumentMeta> | undefined = undefined
let draggedOverPos: 'before' | 'after' | undefined = undefined
let draggedOverTop: number = 0
let cannotDropTo: Ref<DocumentMeta>[] = []
function canDrop (object: Ref<DocumentMeta>, target: Ref<DocumentMeta>): boolean {
if (object === target) return false
if (cannotDropTo.includes(target)) return false
return true
}
function onDragStart (event: DragEvent, object: Ref<DocumentMeta>): void {
// no prevent default to leverage default rendering
// event.preventDefault()
if (event.dataTransfer === null || event.target === null) {
return
}
cannotDropTo = [object, ...tree.descendantsOf(object)]
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.dropEffect = 'move'
draggedItem = object
closeTooltip()
}
function getDropPosition (event: DragEvent): { pos: 'before' | 'after' | undefined, top: number } {
const parentRect = parent.getBoundingClientRect()
const targetRect = (event.target as HTMLElement).getBoundingClientRect()
const dropPosition = event.clientY - targetRect.top
const before = dropPosition >= 0 && dropPosition < targetRect.height / 6
const after = dropPosition <= targetRect.height && dropPosition > (5 * targetRect.height) / 6
const pos = before ? 'before' : after ? 'after' : undefined
const top = pos === 'before' ? targetRect.top - parentRect.top - 1 : targetRect.bottom - parentRect.top - 1
return { pos, top }
}
function onDragOver (event: DragEvent, object: Ref<DocumentMeta>): void {
event.preventDefault()
// this is an ugly solution to control drop effect
// we drag and drop elements that are in the depth of components hierarchy
// so we cannot access them directly
if (!(event.target as HTMLElement).draggable) return
if (event.dataTransfer === null || event.target === null || draggedItem === object) {
return
}
if (draggedItem !== undefined && canDrop(draggedItem, object)) {
event.dataTransfer.dropEffect = 'move'
draggedOver = object
const { pos, top } = getDropPosition(event)
draggedOverPos = pos
draggedOverTop = top
} else {
event.dataTransfer.dropEffect = 'none'
}
}
function onDragEnd (event: DragEvent): void {
event.preventDefault()
draggedItem = undefined
draggedOver = undefined
draggedOverPos = undefined
}
function onDrop (event: DragEvent, object: Ref<DocumentMeta>): void {
event.preventDefault()
if (event.dataTransfer === null) {
return
}
if (draggedItem !== undefined && canDrop(draggedItem, object)) {
const doc = tree.bundleOf(draggedItem)?.ProjectMeta[0]
const target = tree.bundleOf(object)?.ProjectMeta[0]
if (doc !== undefined && target !== undefined && doc._id !== target._id) {
if (object === documents.ids.NoParent) {
void moveDocument(doc, doc.space)
} else if (target !== undefined) {
const { pos } = getDropPosition(event)
if (pos === 'before') {
void moveDocumentBefore(doc, target)
} else if (pos === 'after') {
void moveDocumentAfter(doc, target)
} else if (doc.parent !== object) {
void moveDocument(doc, target.space, target)
}
}
}
}
draggedItem = undefined
draggedOver = undefined
}
</script>
<div bind:this={parent} class="flex-col relative">
{#if draggedOver === documents.ids.NoParent}
<DropArea />
{/if}
{#if draggedOver && draggedOverPos}
<DropMarker top={draggedOverTop} />
{/if}
<TreeNode
_id={space?._id}
folderIcon
iconProps={{
fill: getPlatformColorForTextDef(space.name, $themeStore.dark).icon
}}
title={space.name}
highlighted={space._id === currentSpace && currentFragment !== undefined && !deselect}
visible={(space._id === currentSpace && currentFragment !== undefined && !deselect) || forciblyСollapsed}
showMenu={pressed}
{forciblyСollapsed}
actions={() => getSpaceActions(space)}
type={'nested'}
draggable
on:drop={(evt) => {
onDrop(evt, documents.ids.NoParent)
}}
on:dragover={(evt) => {
onDragOver(evt, documents.ids.NoParent)
}}
on:dragstart={(evt) => {
evt.preventDefault()
}}
>
<svelte:fragment slot="extra">
{#if spaceType?.projects === true}
<ProjectSelector
value={project}
space={space?._id}
maxWidth={'6rem'}
kind={'ghost'}
size={'x-small'}
showDropdownIcon
bind:pressed
on:change={(evt) => {
project = evt.detail
}}
/>
{/if}
</svelte:fragment>
{@const root = tree.childrenOf(documents.ids.NoParent)}
{#if root.length > 0}
<DocHierarchyLevel
{tree}
documentIds={root}
{selected}
getMoreActions={getDocumentActions}
on:selected={(e) => {
handleDocumentSelected(e.detail)
}}
{onDragStart}
{onDragEnd}
{onDragOver}
{onDrop}
{draggedItem}
{draggedOver}
/>
{:else}
<div class="pseudo-element flex-row-center content-dark-color text-md nowrap">
<Label label={documents.string.NoDocuments} />
</div>
{/if}
<svelte:fragment slot="visible">
{#if (selected || forciblyСollapsed) && selectedControlledDoc}
{@const doc = selectedControlledDoc}
<TreeItem
_id={doc._id}
icon={documents.icon.Document}
iconProps={{
fill: 'currentColor'
}}
title={getDocumentName(doc)}
actions={() => getDocumentActions(doc)}
selected
isFold
empty
forciblyСollapsed
/>
{/if}
</svelte:fragment>
</TreeNode>
</div>
<style lang="scss">
.pseudo-element {
height: 2rem;
margin: 0 0.75rem;
padding-left: 2.5rem;
padding-right: 0.75rem;
}
</style>