diff --git a/skore-ui/src/ShareApp.vue b/skore-ui/src/ShareApp.vue index 5e80c6de..4e0d75ca 100644 --- a/skore-ui/src/ShareApp.vue +++ b/skore-ui/src/ShareApp.vue @@ -1,10 +1,22 @@ @@ -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); + } } } diff --git a/skore-ui/src/components/DraggableList.vue b/skore-ui/src/components/DraggableList.vue index d8d6bd3a..5a15a364 100644 --- a/skore-ui/src/components/DraggableList.vue +++ b/skore-ui/src/components/DraggableList.vue @@ -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"; @@ -13,6 +13,7 @@ interface Item { } const items = defineModel("items", { required: true }); +const currentDropPosition = defineModel("currentDropPosition"); const props = defineProps<{ autoScrollContainerSelector?: string; }>(); @@ -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; @@ -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(() => { @@ -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 @@ -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 @@ -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(); }); @@ -236,7 +251,7 @@ onUnmounted(() => { } .draggable { - --content-left-margin: 15px; + --content-left-margin: 18px; position: relative; display: flex; @@ -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 { diff --git a/skore-ui/src/components/DynamicContentRasterizer.vue b/skore-ui/src/components/DynamicContentRasterizer.vue index ae7f2af6..d3b4669c 100644 --- a/skore-ui/src/components/DynamicContentRasterizer.vue +++ b/skore-ui/src/components/DynamicContentRasterizer.vue @@ -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, }; @@ -64,6 +64,7 @@ onBeforeUnmount(() => { diff --git a/skore-ui/src/components/ProjectViewCard.vue b/skore-ui/src/components/ProjectViewCard.vue index 0aec3051..c92e63d7 100644 --- a/skore-ui/src/components/ProjectViewCard.vue +++ b/skore-ui/src/components/ProjectViewCard.vue @@ -44,13 +44,7 @@ const emit = defineEmits<{ position: relative; overflow: auto; max-width: 100%; - padding: var(--spacing-padding-large); - border: solid 1px var(--background-color-normal); - border-radius: var(--border-radius); background-color: var(--background-color-normal); - transition: - background-color var(--transition-duration) var(--transition-easing), - border var(--transition-duration) var(--transition-easing); & .header { display: flex; diff --git a/skore-ui/src/components/TreeAccordionItem.vue b/skore-ui/src/components/TreeAccordionItem.vue index 881b0dd3..84db2fe4 100644 --- a/skore-ui/src/components/TreeAccordionItem.vue +++ b/skore-ui/src/components/TreeAccordionItem.vue @@ -36,7 +36,7 @@ const totalChildrenCount = computed(() => { function onDragStart(event: DragEvent) { if (event.dataTransfer) { - event.dataTransfer.setData("key", props.name); + event.dataTransfer.setData("application/x-skore-item-name", props.name); } } diff --git a/skore-ui/src/router.ts b/skore-ui/src/router.ts index c74fb668..82d5f2e3 100644 --- a/skore-ui/src/router.ts +++ b/skore-ui/src/router.ts @@ -1,6 +1,6 @@ import { createRouter, createWebHashHistory } from "vue-router"; -import ProjectView from "./views/ProjectView.vue"; +import ProjectView from "./views/project/ProjectView.vue"; export enum ROUTE_NAMES { VIEW_BUILDER = "view-builder", diff --git a/skore-ui/src/share.ts b/skore-ui/src/share.ts index abea6e2e..05d4a89e 100644 --- a/skore-ui/src/share.ts +++ b/skore-ui/src/share.ts @@ -15,5 +15,5 @@ export default async function share(selectedView: string) { const m = JSON.parse(document.getElementById("project-data")?.innerText || "{}"); const projectStore = useProjectStore(); await projectStore.setProject(m); - projectStore.currentView = selectedView; + projectStore.setCurrentView(selectedView); } diff --git a/skore-ui/src/stores/project.ts b/skore-ui/src/stores/project.ts index ec30a905..3aa15923 100644 --- a/skore-ui/src/stores/project.ts +++ b/skore-ui/src/stores/project.ts @@ -3,16 +3,25 @@ import { ref, shallowRef } from "vue"; import { type Layout, type Project, type ProjectItem } from "@/models"; import { deleteView as deleteViewApi, fetchProject, putView } from "@/services/api"; -import { poll } from "@/services/utils"; export interface TreeNode { name: string; children: TreeNode[]; } +export interface PresentableItem { + id: string; + key: string; + mediaType: string; + data: any; + createdAt: Date; + updatedAt: Date; +} + export const useProjectStore = defineStore("project", () => { - // this object is not deeply reactive as it may be very large + // this objects is not deeply reactive as it may be very large const items = shallowRef<{ [key: string]: ProjectItem } | null>(null); + const currentViewItems = shallowRef([]); const views = ref<{ [key: string]: Layout }>({}); const currentView = ref(null); @@ -31,12 +40,22 @@ export const useProjectStore = defineStore("project", () => { * Add the value of a key to the view. * @param view the view to add the key to * @param key the key to add to the view + * @param position the position to add the key at, default to the end of the list */ - async function displayKey(view: string, key: string) { + async function displayKey(view: string, key: string, position: number = -1) { stopBackendPolling(); const realKey = key.replace(" (self)", ""); if (!isKeyDisplayed(view, realKey)) { - views.value[view] = [...views.value[view], realKey]; + if (position === -1) { + views.value[view] = [...views.value[view], realKey]; + } else { + views.value[view] = [ + ...views.value[view].slice(0, position), + realKey, + ...views.value[view].slice(position), + ]; + } + _updatePresentableItemsInView(); await persistView(view, views.value[view]); } await startBackendPolling(); @@ -52,6 +71,7 @@ export const useProjectStore = defineStore("project", () => { if (isKeyDisplayed(view, key)) { const v = views.value[view]; views.value[view] = v.filter((k) => k !== key); + _updatePresentableItemsInView(); await persistView(view, views.value[view]); } await startBackendPolling(); @@ -76,7 +96,7 @@ export const useProjectStore = defineStore("project", () => { async function startBackendPolling() { _isCanceledCall = false; await fetch(); - _stopBackendPolling = await poll(fetch, 1500); + _stopBackendPolling = () => {}; // await poll(fetch, 1500); } /** @@ -108,6 +128,7 @@ export const useProjectStore = defineStore("project", () => { async function setProject(r: Project) { items.value = r.items; views.value = r.views; + _updatePresentableItemsInView(); } /** @@ -228,19 +249,78 @@ export const useProjectStore = defineStore("project", () => { await persistView(newName, views.value[newName]); } + /** + * Set the current view + */ + function setCurrentView(view: string) { + currentView.value = view; + _updatePresentableItemsInView(); + } + + /** + * Get the items in the current view as a presentable list. + * @returns a list of items with their metadata + */ + function _updatePresentableItemsInView() { + const r: PresentableItem[] = []; + if (items.value !== null && currentView.value !== null) { + const v = views.value[currentView.value]; + for (const key of v) { + const item = items.value[key]; + if (item) { + const mediaType = item.media_type || ""; + let data; + if ( + [ + "text/markdown", + "application/vnd.dataframe+json", + "application/vnd.sklearn.estimator+html", + "image/png", + "image/jpeg", + "image/webp", + "image/svg+xml", + ].includes(mediaType) + ) { + data = item.value; + } else { + data = atob(item.value); + if (mediaType.includes("json")) { + data = JSON.parse(data); + } + } + const createdAt = new Date(item.created_at); + const updatedAt = new Date(item.updated_at); + r.push({ + id: key, + key, + mediaType, + data, + createdAt, + updatedAt, + }); + } + } + } + currentViewItems.value = r; + } + return { + // refs + currentView, + currentViewItems, items, views, - currentView, + // actions + createView, + deleteView, displayKey, + duplicateView, hideKey, - startBackendPolling, - stopBackendPolling, - setProject, keysAsTree, - createView, - duplicateView, - deleteView, + setCurrentView, + setProject, renameView, + startBackendPolling, + stopBackendPolling, }; }); diff --git a/skore-ui/src/views/ComponentsView.vue b/skore-ui/src/views/ComponentsView.vue index ebad0339..81c00f00 100644 --- a/skore-ui/src/views/ComponentsView.vue +++ b/skore-ui/src/views/ComponentsView.vue @@ -146,6 +146,17 @@ function onEditableListAction(action: string, item: EditableListItemModel) { const lastSelectedItem = ref(null); +function addItemToDraggableList(i: number) { + draggableListData.value.splice(i, 0, { + id: `${i}`, + color: `hsl(${(360 / 25) * i}deg, 90%, 50%)`, + content: Array.from( + { length: Math.floor(Math.random() * 10) + 1 }, // Random number of items between 1 and 10 + () => String.fromCharCode(97 + Math.floor(Math.random() * 26)) // Random lowercase letter + ), + }); +} + const draggableListData = ref( Array.from({ length: 25 }, (v, i) => ({ id: `${i}`, @@ -157,6 +168,22 @@ const draggableListData = ref( })) ); +function onDragStart(event: DragEvent) { + if (event.dataTransfer) { + event.dataTransfer.setData("application/x-skore-item-name", "drag-me"); + } +} + +const currentDropPosition = ref(); + +function onItemDrop(event: DragEvent) { + if (event.dataTransfer) { + if (currentDropPosition.value !== undefined) { + addItemToDraggableList(currentDropPosition.value); + } + } +} + const isCached = ref(false); @@ -434,10 +461,25 @@ const isCached = ref(false); +
Item order: {{ draggableListData.map((item) => item.id).join(", ") }}
+
Drop position: {{ currentDropPosition }}
+
+ +
+ drag me to the list +
+