mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-06 17:57:42 +02:00
UBERF-8316 Documents drag and drop (#6769)
Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>
This commit is contained in:
@@ -46,12 +46,8 @@
|
||||
|
||||
const id: Ref<Document> = generateId()
|
||||
|
||||
const object: Omit<AttachedData<Document>, 'content'> = {
|
||||
name: '',
|
||||
attachments: 0,
|
||||
labels: 0,
|
||||
comments: 0,
|
||||
references: 0
|
||||
const object: Pick<AttachedData<Document>, 'name' | 'icon' | 'color'> = {
|
||||
name: ''
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import document from '../plugin'
|
||||
import TeamspacePresenter from './teamspace/TeamspacePresenter.svelte'
|
||||
import { moveDocument } from '../utils'
|
||||
|
||||
export let value: Document
|
||||
|
||||
@@ -40,10 +41,7 @@
|
||||
async function save (): Promise<void> {
|
||||
const ops = client.apply(value._id)
|
||||
|
||||
await ops.update(value, {
|
||||
space,
|
||||
attachedTo: parent ?? document.ids.NoParent
|
||||
})
|
||||
await moveDocument(value, space, parent ?? document.ids.NoParent)
|
||||
|
||||
if (space !== value.space) {
|
||||
const children = await findChildren(value)
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
import document from '../../plugin'
|
||||
import { createEmptyDocument } from '../../utils'
|
||||
|
||||
import DropArea from './DropArea.svelte'
|
||||
import DocTreeElement from './DocTreeElement.svelte'
|
||||
|
||||
export let documents: Ref<Document>[]
|
||||
@@ -34,11 +35,19 @@
|
||||
export let selected: Ref<Document> | undefined
|
||||
export let level: number = 0
|
||||
|
||||
export let onDragStart: (e: DragEvent, object: Ref<Document>) => void
|
||||
export let onDragOver: (e: DragEvent, object: Ref<Document>) => void
|
||||
export let onDragEnd: (e: DragEvent, object: Ref<Document>) => void
|
||||
export let onDrop: (e: DragEvent, object: Ref<Document>) => void
|
||||
|
||||
export let draggedItem: Ref<Document> | undefined
|
||||
export let draggedOver: Ref<Document> | undefined
|
||||
|
||||
const client = getClient()
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
function getDescendants (obj: Ref<Document>): Ref<Document>[] {
|
||||
return (descendants.get(obj) ?? []).sort((a, b) => a.name.localeCompare(b.name)).map((p) => p._id)
|
||||
return (descendants.get(obj) ?? []).sort((a, b) => a.rank.localeCompare(b.rank)).map((p) => p._id)
|
||||
}
|
||||
|
||||
function getActions (doc: Document): Action[] {
|
||||
@@ -84,32 +93,63 @@
|
||||
</script>
|
||||
|
||||
{#each _documents as doc}
|
||||
{@const desc = _descendants.get(doc._id) ?? []}
|
||||
|
||||
{#if doc}
|
||||
<DocTreeElement
|
||||
{doc}
|
||||
icon={doc.icon === view.ids.IconWithEmoji ? IconWithEmoji : doc.icon ?? document.icon.Document}
|
||||
iconProps={doc.icon === view.ids.IconWithEmoji
|
||||
? { icon: doc.color }
|
||||
: {
|
||||
fill: doc.color !== undefined ? getPlatformColorDef(doc.color, $themeStore.dark).icon : 'currentColor'
|
||||
}}
|
||||
title={doc.name}
|
||||
selected={selected === doc._id}
|
||||
isFold
|
||||
{level}
|
||||
empty={desc.length === 0}
|
||||
actions={getActions(doc)}
|
||||
moreActions={() => getMoreActions(doc)}
|
||||
shouldTooltip
|
||||
on:click={() => {
|
||||
handleDocumentSelected(doc._id)
|
||||
}}
|
||||
>
|
||||
{#if desc.length}
|
||||
<svelte:self documents={desc} {descendants} {documentById} {selected} level={level + 1} on:selected />
|
||||
{@const desc = _descendants.get(doc._id) ?? []}
|
||||
{@const isDraggedOver = draggedOver === doc._id}
|
||||
<div class="flex-col relative">
|
||||
{#if isDraggedOver}
|
||||
<DropArea />
|
||||
{/if}
|
||||
</DocTreeElement>
|
||||
|
||||
<DocTreeElement
|
||||
{doc}
|
||||
icon={doc.icon === view.ids.IconWithEmoji ? IconWithEmoji : doc.icon ?? document.icon.Document}
|
||||
iconProps={doc.icon === view.ids.IconWithEmoji
|
||||
? { icon: doc.color }
|
||||
: {
|
||||
fill: doc.color !== undefined ? getPlatformColorDef(doc.color, $themeStore.dark).icon : 'currentColor'
|
||||
}}
|
||||
title={doc.name}
|
||||
selected={selected === doc._id && draggedItem === undefined}
|
||||
isFold
|
||||
{level}
|
||||
empty={desc.length === 0}
|
||||
actions={getActions(doc)}
|
||||
moreActions={() => getMoreActions(doc)}
|
||||
shouldTooltip
|
||||
on:click={() => {
|
||||
handleDocumentSelected(doc._id)
|
||||
}}
|
||||
on:dragstart={(evt) => {
|
||||
onDragStart(evt, doc._id)
|
||||
}}
|
||||
on:dragover={(evt) => {
|
||||
onDragOver(evt, doc._id)
|
||||
}}
|
||||
on:dragend={(evt) => {
|
||||
onDragEnd(evt, doc._id)
|
||||
}}
|
||||
on:drop={(evt) => {
|
||||
onDrop(evt, doc._id)
|
||||
}}
|
||||
>
|
||||
{#if desc.length}
|
||||
<svelte:self
|
||||
documents={desc}
|
||||
{descendants}
|
||||
{documentById}
|
||||
{selected}
|
||||
level={level + 1}
|
||||
{onDragStart}
|
||||
{onDragOver}
|
||||
{onDragEnd}
|
||||
{onDrop}
|
||||
{draggedItem}
|
||||
{draggedOver}
|
||||
on:selected
|
||||
/>
|
||||
{/if}
|
||||
</DocTreeElement>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
@@ -64,6 +64,11 @@
|
||||
showMenu={hovered}
|
||||
{shouldTooltip}
|
||||
{forciblyСollapsed}
|
||||
draggable
|
||||
on:dragstart
|
||||
on:dragover
|
||||
on:dragend
|
||||
on:drop
|
||||
on:click={() => {
|
||||
selectDocument()
|
||||
dispatch('click')
|
||||
@@ -95,4 +100,5 @@
|
||||
<svelte:fragment slot="dropbox">
|
||||
<slot />
|
||||
</svelte:fragment>
|
||||
<slot name="extra" />
|
||||
</NavItem>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<!--
|
||||
// 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.
|
||||
-->
|
||||
|
||||
<div class="drop-area" />
|
||||
|
||||
<style lang="scss">
|
||||
.drop-area {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
left: 0.75rem;
|
||||
right: 0.75rem;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--global-ui-highlight-BackgroundColor);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,33 @@
|
||||
<!--
|
||||
// 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 top: number
|
||||
</script>
|
||||
|
||||
<div class="drop-marker" style="top: {top}px;" />
|
||||
|
||||
<style lang="scss">
|
||||
.drop-marker {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
height: 0.125rem;
|
||||
background-color: var(--primary-button-focused);
|
||||
|
||||
left: 0.75rem;
|
||||
right: 0.75rem;
|
||||
top: 10rem;
|
||||
}
|
||||
</style>
|
||||
+205
-47
@@ -13,6 +13,7 @@
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Analytics } from '@hcengineering/analytics'
|
||||
import { Ref, SortingOrder, Space, generateId } from '@hcengineering/core'
|
||||
import { Document, DocumentEvents, Teamspace } from '@hcengineering/document'
|
||||
import { createQuery, getClient } from '@hcengineering/presentation'
|
||||
@@ -23,7 +24,8 @@
|
||||
getPlatformColorForTextDef,
|
||||
themeStore,
|
||||
Action,
|
||||
IconAdd
|
||||
IconAdd,
|
||||
closeTooltip
|
||||
} from '@hcengineering/ui'
|
||||
import view from '@hcengineering/view'
|
||||
import { TreeNode, openDoc, getActions as getContributedActions } from '@hcengineering/view-resources'
|
||||
@@ -31,10 +33,17 @@
|
||||
import { getResource } from '@hcengineering/platform'
|
||||
|
||||
import document from '../../plugin'
|
||||
import { getDocumentIdFromFragment, createEmptyDocument } from '../../utils'
|
||||
import {
|
||||
getDocumentIdFromFragment,
|
||||
createEmptyDocument,
|
||||
moveDocument,
|
||||
moveDocumentBefore,
|
||||
moveDocumentAfter
|
||||
} from '../../utils'
|
||||
import DocHierarchy from './DocHierarchy.svelte'
|
||||
import DocTreeElement from './DocTreeElement.svelte'
|
||||
import { Analytics } from '@hcengineering/analytics'
|
||||
import DropArea from './DropArea.svelte'
|
||||
import DropMarker from './DropMarker.svelte'
|
||||
|
||||
export let space: Teamspace
|
||||
export let model: SpacesNavModel
|
||||
@@ -52,7 +61,24 @@
|
||||
let descendants: Map<Ref<Document>, Document[]> = new Map<Ref<Document>, Document[]>()
|
||||
|
||||
function getDescendants (obj: Ref<Document>): Ref<Document>[] {
|
||||
return (descendants.get(obj) ?? []).sort((a, b) => a.name.localeCompare(b.name)).map((p) => p._id)
|
||||
return (descendants.get(obj) ?? []).sort((a, b) => a.rank.localeCompare(b.rank)).map((p) => p._id)
|
||||
}
|
||||
|
||||
function getAllDescendants (obj: Ref<Document>): Ref<Document>[] {
|
||||
const result: Ref<Document>[] = []
|
||||
const queue: Ref<Document>[] = [obj]
|
||||
|
||||
while (queue.length > 0) {
|
||||
const next = queue.pop()
|
||||
if (next === undefined) break
|
||||
|
||||
const children = descendants.get(next) ?? []
|
||||
const childrenRefs = children.map((p) => p._id)
|
||||
result.push(...childrenRefs)
|
||||
queue.push(...childrenRefs)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
let selected: Ref<Document> | undefined
|
||||
@@ -85,7 +111,7 @@
|
||||
},
|
||||
{
|
||||
sort: {
|
||||
name: SortingOrder.Ascending
|
||||
rank: SortingOrder.Ascending
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -125,48 +151,180 @@
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
let parent: HTMLElement
|
||||
let draggedItem: Ref<Document> | undefined = undefined
|
||||
let draggedOver: Ref<Document> | undefined = undefined
|
||||
let draggedOverPos: 'before' | 'after' | undefined = undefined
|
||||
let draggedOverTop: number = 0
|
||||
let cannotDropTo: Ref<Document>[] = []
|
||||
|
||||
function canDrop (object: Ref<Document>, target: Ref<Document>): boolean {
|
||||
if (object === target) return false
|
||||
if (cannotDropTo.includes(target)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function onDragStart (event: DragEvent, object: Ref<Document>): void {
|
||||
// no prevent default to leverage default rendering
|
||||
// event.preventDefault()
|
||||
if (event.dataTransfer === null || event.target === null) {
|
||||
return
|
||||
}
|
||||
|
||||
cannotDropTo = [object, ...getAllDescendants(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<Document>): 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<Document>): void {
|
||||
event.preventDefault()
|
||||
if (event.dataTransfer === null) {
|
||||
return
|
||||
}
|
||||
if (draggedItem !== undefined && canDrop(draggedItem, object)) {
|
||||
const doc = documentById.get(draggedItem)
|
||||
const target = documentById.get(object)
|
||||
|
||||
if (doc !== undefined && doc._id !== object) {
|
||||
if (object === document.ids.NoParent) {
|
||||
void moveDocument(doc, doc.space, document.ids.NoParent)
|
||||
} 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.attachedTo !== object) {
|
||||
void moveDocument(doc, target.space, target._id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
draggedItem = undefined
|
||||
draggedOver = undefined
|
||||
}
|
||||
</script>
|
||||
|
||||
<TreeNode
|
||||
_id={space?._id}
|
||||
icon={space?.icon === view.ids.IconWithEmoji ? IconWithEmoji : space?.icon ?? model.icon}
|
||||
iconProps={space?.icon === view.ids.IconWithEmoji
|
||||
? { icon: space.color }
|
||||
: {
|
||||
fill:
|
||||
space.color !== undefined
|
||||
? getPlatformColorDef(space.color, $themeStore.dark).icon
|
||||
: getPlatformColorForTextDef(space.name, $themeStore.dark).icon
|
||||
}}
|
||||
title={space.name}
|
||||
type={'nested'}
|
||||
highlighted={currentSpace === space._id}
|
||||
visible={currentSpace === space._id || forciblyСollapsed}
|
||||
actions={() => getActions(space)}
|
||||
{forciblyСollapsed}
|
||||
>
|
||||
<DocHierarchy {documents} {descendants} {documentById} {selected} />
|
||||
<div bind:this={parent} class="flex-col relative">
|
||||
{#if draggedOver === document.ids.NoParent}
|
||||
<DropArea />
|
||||
{/if}
|
||||
|
||||
<svelte:fragment slot="visible">
|
||||
{#if (selected || forciblyСollapsed) && visibleItem}
|
||||
{@const item = visibleItem}
|
||||
<DocTreeElement
|
||||
doc={item}
|
||||
icon={item.icon === view.ids.IconWithEmoji ? IconWithEmoji : item.icon ?? document.icon.Document}
|
||||
iconProps={item.icon === view.ids.IconWithEmoji
|
||||
? { icon: visibleItem.color }
|
||||
: {
|
||||
fill: item.color !== undefined ? getPlatformColorDef(item.color, $themeStore.dark).icon : 'currentColor'
|
||||
}}
|
||||
title={item.name}
|
||||
selected
|
||||
isFold
|
||||
empty
|
||||
shouldTooltip
|
||||
actions={getDocActions(item)}
|
||||
moreActions={() => getMoreActions(item)}
|
||||
forciblyСollapsed
|
||||
/>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
</TreeNode>
|
||||
{#if draggedOver && draggedOverPos}
|
||||
<DropMarker top={draggedOverTop} />
|
||||
{/if}
|
||||
|
||||
<TreeNode
|
||||
_id={space?._id}
|
||||
icon={space?.icon === view.ids.IconWithEmoji ? IconWithEmoji : space?.icon ?? model.icon}
|
||||
iconProps={space?.icon === view.ids.IconWithEmoji
|
||||
? { icon: space.color }
|
||||
: {
|
||||
fill:
|
||||
space.color !== undefined
|
||||
? getPlatformColorDef(space.color, $themeStore.dark).icon
|
||||
: getPlatformColorForTextDef(space.name, $themeStore.dark).icon
|
||||
}}
|
||||
title={space.name}
|
||||
type={'nested'}
|
||||
highlighted={currentSpace === space._id}
|
||||
visible={currentSpace === space._id || forciblyСollapsed}
|
||||
actions={() => getActions(space)}
|
||||
selected={draggedOver === document.ids.NoParent}
|
||||
{forciblyСollapsed}
|
||||
draggable
|
||||
on:drop={(evt) => {
|
||||
onDrop(evt, document.ids.NoParent)
|
||||
}}
|
||||
on:dragover={(evt) => {
|
||||
onDragOver(evt, document.ids.NoParent)
|
||||
}}
|
||||
on:dragstart={(evt) => {
|
||||
evt.preventDefault()
|
||||
}}
|
||||
>
|
||||
<DocHierarchy
|
||||
{documents}
|
||||
{descendants}
|
||||
{documentById}
|
||||
{selected}
|
||||
{onDragStart}
|
||||
{onDragEnd}
|
||||
{onDragOver}
|
||||
{onDrop}
|
||||
{draggedItem}
|
||||
{draggedOver}
|
||||
/>
|
||||
<svelte:fragment slot="visible">
|
||||
{#if (selected || forciblyСollapsed) && visibleItem}
|
||||
{@const item = visibleItem}
|
||||
<DocTreeElement
|
||||
doc={item}
|
||||
icon={item.icon === view.ids.IconWithEmoji ? IconWithEmoji : item.icon ?? document.icon.Document}
|
||||
iconProps={item.icon === view.ids.IconWithEmoji
|
||||
? { icon: visibleItem.color }
|
||||
: {
|
||||
fill: item.color !== undefined ? getPlatformColorDef(item.color, $themeStore.dark).icon : 'currentColor'
|
||||
}}
|
||||
title={item.name}
|
||||
selected
|
||||
isFold
|
||||
empty
|
||||
shouldTooltip
|
||||
actions={getDocActions(item)}
|
||||
moreActions={() => getMoreActions(item)}
|
||||
forciblyСollapsed
|
||||
/>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
</TreeNode>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user