Skip to content

Commit

Permalink
feat(UI): Allow view items to be drag and drop (#489)
Browse files Browse the repository at this point in the history
This PR enable drag'n'drop management of view items.

- Item of view can now be dragged from the left tree to the right pane
at a specific position (including auto scroll)
- Item can be reorganized using drag'n'drop

**UI preview**


https://github.com/user-attachments/assets/5832ca08-63f1-4152-8834-956ea23583c9
  • Loading branch information
rouk1 authored Oct 18, 2024
1 parent 0175c71 commit 0fa7a2e
Show file tree
Hide file tree
Showing 15 changed files with 731 additions and 601 deletions.
80 changes: 51 additions & 29 deletions skore-ui/src/ShareApp.vue
Original file line number Diff line number Diff line change
@@ -1,19 +1,60 @@
<script setup lang="ts">
import { formatDistance } from "date-fns";
import Simplebar from "simplebar-vue";
import ProjectViewCanvas from "@/components/ProjectViewCanvas.vue";
import DataFrameWidget from "@/components/DataFrameWidget.vue";
import HtmlSnippetWidget from "@/components/HtmlSnippetWidget.vue";
import ImageWidget from "@/components/ImageWidget.vue";
import MarkdownWidget from "@/components/MarkdownWidget.vue";
import PlotlyWidget from "@/components/PlotlyWidget.vue";
import ProjectViewCard from "@/components/ProjectViewCard.vue";
import VegaWidget from "@/components/VegaWidget.vue";
import { useProjectStore } from "@/stores/project";
const projectStore = useProjectStore();
function getItemSubtitle(created_at: Date, updated_at: Date) {
const now = new Date();
return `Created ${formatDistance(created_at, now)} ago, updated ${formatDistance(updated_at, now)} ago`;
}
</script>

<template>
<div class="share">
<div class="share-header">
<h1>{{ projectStore.currentView }}</h1>
</div>
<Simplebar class="canvas-wrapper">
<ProjectViewCanvas :showCardActions="false" />
<Simplebar class="cards">
<div class="inner">
<ProjectViewCard
v-for="{ key, mediaType, data, createdAt, updatedAt } in projectStore.currentViewItems"
:key="key"
:title="key.toString()"
:subtitle="getItemSubtitle(createdAt, updatedAt)"
:showActions="false"
>
<DataFrameWidget
v-if="mediaType === 'application/vnd.dataframe+json'"
:columns="data.columns"
:data="data.data"
:index="data.index"
/>
<ImageWidget
v-if="['image/svg+xml', 'image/png', 'image/jpeg', 'image/webp'].includes(mediaType)"
:mediaType="mediaType"
:base64-src="data"
:alt="key.toString()"
/>
<MarkdownWidget v-if="mediaType === 'text/markdown'" :source="data" />
<VegaWidget v-if="mediaType === 'application/vnd.vega.v5+json'" :spec="data" />
<PlotlyWidget v-if="mediaType === 'application/vnd.plotly.v1+json'" :spec="data" />
<HtmlSnippetWidget
v-if="mediaType === 'application/vnd.sklearn.estimator+html'"
:src="data"
/>
<HtmlSnippetWidget v-if="mediaType === 'text/html'" :src="data" />
</ProjectViewCard>
</div>
</Simplebar>
</div>
</template>
Expand Down Expand Up @@ -56,35 +97,16 @@ const projectStore = useProjectStore();
}
}
& .drop-indicator {
height: 3px;
border-radius: 8px;
margin: 0 10%;
background-color: var(--text-color-title);
opacity: 0;
transition: opacity var(--transition-duration) var(--transition-easing);
&.visible {
opacity: 1;
}
}
& .placeholder {
height: 100%;
padding-top: calc((100vh - var(--header-height)) * 476 / 730);
background-image: var(--editor-placeholder-image);
background-position: 50%;
background-repeat: no-repeat;
background-size: contain;
color: var(--text-color-normal);
font-size: var(--text-size-normal);
text-align: center;
}
& .canvas-wrapper {
& .cards {
height: 0;
flex-grow: 1;
padding: var(--spacing-padding-large);
& .inner {
display: flex;
flex-direction: column;
gap: var(--spacing-gap-large);
}
}
}
</style>
122 changes: 71 additions & 51 deletions skore-ui/src/components/DraggableList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { Interactable } from "@interactjs/types";
import { toPng } from "html-to-image";
import interact from "interactjs";
import Simplebar from "simplebar-core";
import { onMounted, onUnmounted, ref, useTemplateRef } from "vue";
import { onBeforeUnmount, onMounted, ref, useTemplateRef } from "vue";
import DynamicContentRasterizer from "@/components/DynamicContentRasterizer.vue";
Expand All @@ -13,6 +13,7 @@ interface Item {
}
const items = defineModel<Item[]>("items", { required: true });
const currentDropPosition = defineModel<number>("currentDropPosition");
const props = defineProps<{
autoScrollContainerSelector?: string;
}>();
Expand All @@ -22,7 +23,7 @@ const movingItemAsPngData = ref("");
const movingItemHeight = ref(0);
const movingItemY = ref(0);
const container = useTemplateRef("container");
let interactable: Interactable;
let draggable: Interactable;
let direction: "up" | "down" | "none" = "none";
let autoScrollContainer: HTMLElement = document.body;
Expand Down Expand Up @@ -64,21 +65,55 @@ function capturedStyles() {
};
}
function makeModifier() {
const containerBounds = container.value!.getBoundingClientRect();
return interact.modifiers.restrict({
restriction: (x, y, { element }) => {
const content = element?.parentElement?.parentElement?.querySelector(".content");
const { height } = content?.getBoundingClientRect() ?? { height: 0 };
return {
top: containerBounds?.top - height,
right: containerBounds?.right,
bottom: containerBounds?.bottom + height,
left: containerBounds?.left,
};
},
function setDropIndicatorPosition(y: number) {
const itemBounds = Array.from(container.value!.querySelectorAll(".item")).map((item, index) => {
const { top, height } = item.getBoundingClientRect();
const center = top + height / 2;
return {
index,
distance: Math.abs(y - center),
center,
};
});
const closestItemBelow = itemBounds.reduce((closest, item) => {
if (item.distance < closest.distance) {
return item;
}
return closest;
}, itemBounds[0]);
if (y > closestItemBelow.center) {
dropIndicatorPosition.value = closestItemBelow.index;
} else {
dropIndicatorPosition.value = closestItemBelow.index - 1;
}
}
function onDragOver(event: DragEvent) {
// scroll the container if needed
const scrollBounds = autoScrollContainer.getBoundingClientRect();
const distanceToTop = Math.abs(event.pageY - scrollBounds.top);
const distanceToBottom = Math.abs(event.pageY - scrollBounds.bottom);
const threshold = 150;
const speed = 5;
if (distanceToTop < threshold) {
autoScrollContainer.scrollTop -= speed;
} else if (distanceToBottom < threshold) {
const maxScroll = autoScrollContainer.scrollHeight - scrollBounds.height;
autoScrollContainer.scrollTop = Math.min(maxScroll, autoScrollContainer.scrollTop + speed);
}
// show drop indicator to the closest item
setDropIndicatorPosition(event.pageY);
if (dropIndicatorPosition.value !== null) {
currentDropPosition.value = dropIndicatorPosition.value + 1;
}
}
function onDragLeave() {
currentDropPosition.value = -1;
dropIndicatorPosition.value = null;
}
onMounted(() => {
Expand All @@ -101,14 +136,14 @@ onMounted(() => {
autoScrollContainer = container.value!.parentElement ?? document.body;
}
interactable = interact(".handle").draggable({
draggable = interact(".handle").draggable({
autoScroll: {
enabled: true,
container: autoScrollContainer,
speed: 900,
},
startAxis: "y",
lockAxis: "y",
modifiers: [makeModifier()],
listeners: {
async start(event) {
// make a rasterized copy of the moving element
Expand All @@ -134,40 +169,13 @@ onMounted(() => {
event.clientY + autoScrollContainer!.scrollTop - paddingTop - containerY;
// set the drop indicator item index
const itemBounds = Array.from(container.value!.querySelectorAll(".item")).map(
(item, index) => {
const { top, height } = item.getBoundingClientRect();
const center = top + height / 2;
return {
index,
distance: Math.abs(event.pageY - center),
};
}
);
const closestItemBelow = itemBounds.reduce((closest, item) => {
if (item.distance < closest.distance) {
return item;
}
return closest;
}, itemBounds[0]);
// if the first item is the closest we may need to move the drop indicator up
if (closestItemBelow.index === 0) {
// does the user want to move the item to the top?
const bounds = container.value!.getBoundingClientRect();
if (event.pageY < bounds.top) {
dropIndicatorPosition.value = -1;
} else {
dropIndicatorPosition.value = 0;
}
} else {
dropIndicatorPosition.value = closestItemBelow.index;
}
setDropIndicatorPosition(event.pageY);
},
end() {
// change the model order
if (items.value && movingItemIndex.value !== null && dropIndicatorPosition.value !== null) {
// did user dropped the item in its previous position ?
if (Math.abs(dropIndicatorPosition.value - movingItemIndex.value) > 1) {
if (Math.abs(dropIndicatorPosition.value - movingItemIndex.value) >= 1) {
// move the item to its new position
const destinationIndex =
dropIndicatorPosition.value > movingItemIndex.value
Expand All @@ -186,10 +194,17 @@ onMounted(() => {
},
},
});
container.value!.addEventListener("dragover", onDragOver);
container.value!.addEventListener("dragleave", onDragLeave);
window.addEventListener("dragend", onDragLeave);
});
onUnmounted(() => {
interactable.unset();
onBeforeUnmount(() => {
container.value!.removeEventListener("dragover", onDragOver);
container.value!.removeEventListener("dragleave", onDragLeave);
window.removeEventListener("dragend", onDragLeave);
draggable.unset();
});
</script>

Expand Down Expand Up @@ -236,7 +251,7 @@ onUnmounted(() => {
}
.draggable {
--content-left-margin: 15px;
--content-left-margin: 18px;
position: relative;
display: flex;
Expand All @@ -249,18 +264,23 @@ onUnmounted(() => {
& .handle {
position: absolute;
top: 0;
top: -4px;
left: 0;
display: flex;
color: hsl(from var(--color-primary) h s calc(l * 1.5));
cursor: move;
font-size: calc(var(--text-size-normal) * 1.7);
opacity: 0;
transition: opacity var(--transition-duration) var(--transition-easing);
}
& .content-wrapper {
width: calc(100% - var(--content-left-margin));
margin-left: var(--content-left-margin);
}
& .content {
width: 100%;
transition: opacity var(--transition-duration) var(--transition-easing);
&.moving {
Expand Down
3 changes: 2 additions & 1 deletion skore-ui/src/components/DynamicContentRasterizer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const realContent = useTemplateRef("realContent");
const contentHeight = ref(0);
const styles = computed(() => {
const h = contentHeight.value !== 0 ? `${contentHeight.value}px` : "auto";
const h = contentHeight.value !== 0 && props.isRasterized ? `${contentHeight.value}px` : "auto";
return {
height: h,
};
Expand Down Expand Up @@ -64,6 +64,7 @@ onBeforeUnmount(() => {
<style scoped>
.dynamic-content-rasterizer {
position: relative;
overflow: hidden;
padding: 0;
margin: 0;
Expand Down
Loading

0 comments on commit 0fa7a2e

Please sign in to comment.