feat(frontend): components can be moved inside containers by holding ctrl/cmd

* feat(frontend): wip

* feat(frontend): wip

* feat(frontend): wip

* feat(frontend): move component inside containers

* feat(frontend): done

* feat(frontend): done

* fix(frontend): wip

* fix(frontend): wip

* fix(frontend): done

* fix(frontend): done

* fix(frontend): fix anchored

* fix(frontend): restore event

* feat(frontend): support dropping on the top level + hide the shadow when leaving the grid

* feat(frontend): drop in the correct subgrid when there are multiple

* feat(frontend): wip

* feat(frontend): fix hide shadow

* feat(frontend): remove dead code

* feat(frontend): wip

* feat(frontend): fix overflow

* feat(frontend): wip

* feat(frontend): done

* feat(frontend): fix z-index

* feat(frontend): wip

* feat(frontend): fix shadow

* feat(frontend): fix resize + drop position

* feat(frontend): fix dropped when no movement

* feat(frontend): hide the fake shadow if the move mode is set

* feat(frontend): add small timeout when we leave the insert mode

* feat(frontend): render the shadow where the component will drop

* feat(frontend): done

* feat(frontend): done

* feat(frontend): improve perf

* feat(frontend): done

* feat(frontend): remove console.log

* feat(frontend): fix move toplevel

* feat(frontend): Components positions are restored when the mode changes

* feat(frontend): Components positions are restored when the mode changes

* feat(frontend): Components positions are restored when the mode changes

* feat(frontend): fix edges cases + improve perf

* feat(frontend): fix drop position

* feat(frontend): fix final drop position

* feat(frontend): fix final drop position

* feat(frontend): fix insert from component bar

* feat(frontend): Fix disapearing components
This commit is contained in:
Faton Ramadani
2024-10-09 13:08:16 +02:00
committed by GitHub
parent 257f0971f8
commit 111bfc6a65
9 changed files with 883 additions and 53 deletions
+20
View File
@@ -145,3 +145,23 @@ svelte-virtual-list-contents > * + * {
rgba(255, 69, 58, 0.2) 20px
);
}
.bg-draggedover {
background-image: repeating-linear-gradient(
-45deg,
rgba(0, 0, 128, 0.2),
rgba(0, 0, 192, 0.2) 10px,
rgba(0, 0, 128, 0.2) 10px,
rgba(0, 0, 192, 0.2) 20px
);
}
.bg-draggedover-dark {
background-image: repeating-linear-gradient(
-45deg,
rgba(0, 0, 128, 0.6),
rgba(0, 0, 192, 0.6) 10px,
rgba(0, 0, 128, 0.6) 10px,
rgba(0, 0, 192, 0.6) 20px
);
}
@@ -19,11 +19,11 @@
import AppEditorHeader from './AppEditorHeader.svelte'
import GridEditor from './GridEditor.svelte'
import { Button, Tab } from '$lib/components/common'
import { Alert, Button, Tab } from '$lib/components/common'
import TabContent from '$lib/components/common/tabs/TabContent.svelte'
import Tabs from '$lib/components/common/tabs/Tabs.svelte'
import { userStore, workspaceStore } from '$lib/stores'
import { classNames, encodeState, sendUserToast } from '$lib/utils'
import { classNames, encodeState, getModifierKey, sendUserToast } from '$lib/utils'
import AppPreview from './AppPreview.svelte'
import ComponentList from './componentsPanel/ComponentList.svelte'
import ContextPanel from './contextPanel/ContextPanel.svelte'
@@ -693,7 +693,15 @@
{#if $componentActive}
<div
class="absolute z-50 inset-0 h-full w-full bg-surface-secondary [background-size:16px_16px]"
/>
>
<div class="w-min whitespace-nowrap mx-auto pt-0.5 z-50">
<Alert
title={`Press ${getModifierKey()} to drop component inside a container.`}
size="xs"
class="h-10 py-1"
/>
</div>
</div>
{/if}
<SplitPanesWrapper>
@@ -1,13 +1,21 @@
<script lang="ts">
import { getContext } from 'svelte'
import type { AppEditorContext, AppViewerContext } from '../types'
import { columnConfiguration, isFixed, toggleFixed } from '../gridUtils'
import { columnConfiguration, gridColumns, isFixed, toggleFixed } from '../gridUtils'
import { twMerge } from 'tailwind-merge'
import HiddenComponent from '../components/helpers/HiddenComponent.svelte'
import Component from './component/Component.svelte'
import { push } from '$lib/history'
import { dfs, expandGriditem, findGridItem } from './appUtils'
import {
dfs,
expandGriditem,
findGridItem,
findGridItemParentGrid,
insertNewGridItem,
isContainer,
subGridIndexKey
} from './appUtils'
import Grid from '../svelte-grid/Grid.svelte'
import { deepEqual } from 'fast-equals'
import ComponentWrapper from './component/ComponentWrapper.svelte'
@@ -32,7 +40,8 @@
parentWidth,
breakpoint,
allIdsInPath,
bgRuns
bgRuns,
worldStore
} = getContext<AppViewerContext>('AppViewerContext')
const { history, scale, componentActive } = getContext<AppEditorContext>('AppEditorContext')
@@ -61,6 +70,53 @@
}
$app = $app
}
export function moveComponentBetweenSubgrids(
componentId: string,
parentComponentId: string,
subGridIndex: number,
position?: { x: number; y: number }
) {
// Find the component in the source subgrid
const component = findGridItem($app, componentId)
if (!component) {
return
}
let parentGrid = findGridItemParentGrid($app, component.id)
if (parentGrid) {
$app.subgrids &&
($app.subgrids[parentGrid] = $app.subgrids[parentGrid].filter(
(item) => item.id !== component?.id
))
} else {
$app.grid = $app.grid.filter((item) => item.id !== component?.id)
}
const gridItem = component
insertNewGridItem(
$app,
(id) => ({ ...gridItem.data, id }),
{ parentComponentId: parentComponentId, subGridIndex: subGridIndex },
Object.fromEntries(gridColumns.map((column) => [column, gridItem[column]])),
component.id,
position,
undefined,
undefined,
undefined,
true
)
// Update the app state
$app = { ...$app }
$selectedComponent = [parentComponentId]
$focusedGrid = {
parentComponentId,
subGridIndex
}
}
</script>
<div class="w-full z-[1000] overflow-visible h-full">
@@ -134,10 +190,37 @@
push(history, $app)
$app.grid = e.detail
}}
root
let:dataItem
let:hidden
let:overlapped
let:moveMode
let:componentDraggedId
cols={columnConfiguration}
on:dropped={(e) => {
const { id, overlapped, x, y } = e.detail
const overlappedComponent = findGridItem($app, overlapped)
if (overlappedComponent && !isContainer(overlappedComponent.data.type)) {
return
}
if (!overlapped) {
return
}
if (id === overlapped) {
return
}
moveComponentBetweenSubgrids(
id,
overlapped,
subGridIndexKey(overlappedComponent?.data?.type, overlapped, $worldStore),
{ x, y }
)
}}
>
<ComponentWrapper
id={dataItem.id}
@@ -177,7 +260,9 @@
on:fillHeight={() => {
handleFillHeight(dataItem.id)
}}
overlapped={overlapped !== undefined}
{overlapped}
{moveMode}
{componentDraggedId}
/>
</GridEditorMenu>
</ComponentWrapper>
@@ -3,10 +3,19 @@
import { classNames } from '$lib/utils'
import { createEventDispatcher, getContext, onDestroy } from 'svelte'
import { twMerge } from 'tailwind-merge'
import { columnConfiguration, isFixed, toggleFixed } from '../gridUtils'
import { columnConfiguration, gridColumns, isFixed, toggleFixed } from '../gridUtils'
import Grid from '../svelte-grid/Grid.svelte'
import type { AppEditorContext, AppViewerContext, GridItem } from '../types'
import { expandGriditem, findGridItem, maxHeight, selectId } from './appUtils'
import {
expandGriditem,
findGridItem,
findGridItemParentGrid,
insertNewGridItem,
isContainer,
maxHeight,
selectId,
subGridIndexKey
} from './appUtils'
import Component from './component/Component.svelte'
import ComponentWrapper from './component/ComponentWrapper.svelte'
import GridViewer from './GridViewer.svelte'
@@ -15,6 +24,7 @@
export let containerHeight: number | undefined = undefined
export let containerWidth: number | undefined = undefined
let classes = ''
export { classes as class }
export let style = ''
export let noPadding = false
@@ -34,7 +44,8 @@
mode,
parentWidth,
breakpoint,
allIdsInPath
allIdsInPath,
worldStore
} = getContext<AppViewerContext>('AppViewerContext')
const editorContext = getContext<AppEditorContext>('AppEditorContext')
@@ -68,6 +79,98 @@
let container: HTMLElement | undefined = undefined
$: maxRow = maxHeight($app.subgrids?.[subGridId] ?? [], containerHeight ?? 0, $breakpoint)
export function moveComponentBetweenSubgrids(
componentId: string,
parentComponentId: string,
subGridIndex: number,
position?: { x: number; y: number }
) {
// Find the component in the source subgrid
const component = findGridItem($app, componentId)
if (!component) {
return
}
let parentGrid = findGridItemParentGrid($app, component.id)
if (parentGrid) {
$app.subgrids &&
($app.subgrids[parentGrid] = $app.subgrids[parentGrid].filter(
(item) => item.id !== component?.id
))
} else {
$app.grid = $app.grid.filter((item) => item.id !== component?.id)
}
const gridItem = component
insertNewGridItem(
$app,
(id) => ({ ...gridItem.data, id }),
{ parentComponentId: parentComponentId, subGridIndex: subGridIndex },
Object.fromEntries(gridColumns.map((column) => [column, gridItem[column]])),
component.id,
position,
undefined,
undefined,
undefined,
true
)
// Update the app state
$app = { ...$app }
if (parentGrid) {
$focusedGrid = {
parentComponentId,
subGridIndex
}
$selectedComponent = [parentComponentId]
} else {
$focusedGrid = undefined
}
}
export function moveToRoot(componentId: string, position?: { x: number; y: number }) {
// Find the component in the source subgrid
const component = findGridItem($app, componentId)
if (!component) {
return
}
let parentGrid = findGridItemParentGrid($app, component.id)
if (parentGrid) {
$app.subgrids &&
($app.subgrids[parentGrid] = $app.subgrids[parentGrid].filter(
(item) => item.id !== component?.id
))
} else {
$app.grid = $app.grid.filter((item) => item.id !== component?.id)
}
const gridItem = component
insertNewGridItem(
$app,
(id) => ({ ...gridItem.data, id }),
undefined,
Object.fromEntries(gridColumns.map((column) => [column, gridItem[column]])),
component.id,
position,
undefined,
undefined,
undefined,
true
)
// Update the app state
$app = { ...$app }
}
</script>
<div
@@ -109,10 +212,39 @@
let:dataItem
let:hidden
let:overlapped
let:moveMode
let:componentDraggedId
cols={columnConfiguration}
scroller={container}
parentWidth={$parentWidth - 17}
{containerWidth}
on:dropped={(e) => {
const { id, overlapped, x, y } = e.detail
if (!overlapped) {
moveToRoot(id, { x, y })
} else {
const overlappedComponent = findGridItem($app, overlapped)
if (overlappedComponent && !isContainer(overlappedComponent.data.type)) {
return
}
if (!overlapped) {
return
}
if (id === overlapped) {
return
}
moveComponentBetweenSubgrids(
id,
overlapped,
subGridIndexKey(overlappedComponent?.data?.type, overlapped, $worldStore),
{ x, y }
)
}
}}
>
<ComponentWrapper
id={dataItem.id}
@@ -160,6 +292,8 @@
}
$app = $app
}}
{moveMode}
{componentDraggedId}
/>
</GridEditorMenu>
</ComponentWrapper>
@@ -20,7 +20,6 @@ import {
import { gridColumns } from '../gridUtils'
import { allItems } from '../utils'
import type { Output, World } from '../rx'
import gridHelp from '../svelte-grid/utils/helper'
import type { FilledItem, Size } from '../svelte-grid/types'
import type {
StaticAppInput,
@@ -35,6 +34,7 @@ import { deepMergeWithPriority } from '$lib/utils'
import { sendUserToast } from '$lib/toast'
import { getNextId } from '$lib/components/flows/idUtils'
import { enterpriseLicense } from '$lib/stores'
import gridHelp from '../svelte-grid/utils/helper'
export function findComponentSettings(app: App, id: string | undefined) {
if (!id) return undefined
@@ -245,7 +245,8 @@ export function createNewGridItem(
columns?: Record<number, any>,
initialPosition: { x: number; y: number } = { x: 0, y: 0 },
recOverride?: Record<number, Size>,
fixed?: boolean
fixed?: boolean,
shouldNotFindSpace?: boolean
): GridItem {
const newComponent = {
fixed: fixed ?? false,
@@ -272,11 +273,28 @@ export function createNewGridItem(
h: rec.h
}
} else {
newItem[column] = columns[column]
newItem[column] = {
...columns[column],
x: initialPosition.x,
y: initialPosition.y
}
}
const position = gridHelp.findSpace(newItem, grid, column) as { x: number; y: number }
newItem[column] = { ...newItem[column], ...position }
let shouldComputePosition: boolean = false
// Fallback to avoid component disapearing
if (initialPosition.x === undefined || initialPosition.y === undefined) {
newItem[column].x = 0
newItem[column].y = 0
shouldComputePosition = true
}
// Either the final position is controlled using initialPosition or the position is computed because the component positions are wrong
if (!shouldNotFindSpace || shouldComputePosition) {
const position = gridHelp.findSpace(newItem, grid, column) as { x: number; y: number }
newItem[column] = { ...newItem[column], ...position }
}
})
return newItem
@@ -392,7 +410,8 @@ export function insertNewGridItem(
initialPosition: { x: number; y: number } = { x: 0, y: 0 },
recOverride?: Record<number, Size>,
keepSubgrids?: boolean,
fixed?: boolean
fixed?: boolean,
shouldNotFindSpace?: boolean
): string {
const id = keepId ?? getNextGridItemId(app)
@@ -439,7 +458,16 @@ export function insertNewGridItem(
let grid = focusedGrid ? app.subgrids[key!] : app.grid
const newItem = createNewGridItem(grid, id, data, columns, initialPosition, recOverride, fixed)
const newItem = createNewGridItem(
grid,
id,
data,
columns,
initialPosition,
recOverride,
fixed,
shouldNotFindSpace
)
grid.push(newItem)
return id
}
@@ -1135,3 +1163,120 @@ export function setUpTopBarComponentContent(id: string, app: App) {
}
)
}
export function isContainer(type: string): boolean {
return (
type === 'containercomponent' ||
type === 'tabscomponent' ||
type === 'verticalsplitpanescomponent' ||
type === 'horizontalsplitpanescomponent' ||
type === 'steppercomponent' ||
type === 'listcomponent' ||
type === 'decisiontreecomponent'
)
}
export function subGridIndexKey(type: string | undefined, id: string, world: World): number {
switch (type) {
case 'containercomponent':
case 'verticalsplitpanescomponent':
case 'horizontalsplitpanescomponent':
case 'listcomponent':
return 0
case 'tabscomponent': {
return (world?.outputsById?.[id]?.selectedTabIndex?.peak() as number) ?? 0
}
case 'steppercomponent': {
return (world?.outputsById?.[id]?.currentStepIndex?.peak() as number) ?? 0
}
case 'decisiontreecomponent': {
return (world?.outputsById?.[id]?.currentNodeIndex?.peak() as number) ?? 0
}
}
return 0
}
export function computePosition(
clientX: number,
clientY: number,
xPerPx: number,
yPerPx: number,
overlapped?: string,
element?: HTMLElement
) {
const overlappedElement = overlapped
? document.getElementById(`component-${overlapped}`)
: document.getElementById('root-grid')
const xRelativeToElement = element ? clientX - element.getBoundingClientRect().left : 0
const yRelativeToElement = element ? clientY - element.getBoundingClientRect().top : 0
const xRelativeToOverlappedElement = overlappedElement
? clientX - overlappedElement.getBoundingClientRect().left - xRelativeToElement
: 0
const yRelativeToOverlappedElement = overlappedElement
? clientY - overlappedElement.getBoundingClientRect().top - yRelativeToElement
: 0
const gridX = Math.max(Math.round(xRelativeToOverlappedElement / xPerPx) ?? 0, 0)
const gridY = Math.max(Math.round(yRelativeToOverlappedElement / yPerPx) ?? 0, 0)
return {
x: gridX,
y: gridY
}
}
export function getDeltaYByComponent(type: string) {
switch (type) {
case 'steppercomponent': {
return '36px + 0.5rem'
}
case 'tabscomponent': {
return '32px'
}
default:
return '0px'
}
}
export function getDeltaXByComponent(type: string) {
switch (type) {
case 'steppercomponent': {
return '0.5rem'
}
case 'tabscomponent': {
return '0px'
}
default:
return '0px'
}
}
export type GridShadow = {
x: number
y: number
xPerPx: number
yPerPx: number
w: number
h: number
}
export function areShadowsTheSame(
shadow1: GridShadow | undefined,
shadow2: GridShadow | undefined
) {
if (!shadow1 || !shadow2) {
return false
}
return (
shadow1.x === shadow2.x &&
shadow1.y === shadow2.y &&
shadow1.xPerPx === shadow2.xPerPx &&
shadow1.yPerPx === shadow2.yPerPx &&
shadow1.w === shadow2.w &&
shadow1.h === shadow2.h
)
}
@@ -81,6 +81,7 @@
import AppRecomputeAll from '../../components/display/AppRecomputeAll.svelte'
import AppUserResource from '../../components/inputs/AppUserResource.svelte'
import { Anchor } from 'lucide-svelte'
import { findGridItemParentGrid, isContainer } from '../appUtils'
export let component: AppComponent
export let selected: boolean
@@ -88,9 +89,10 @@
export let render: boolean
export let hidden: boolean
export let fullHeight: boolean
export let overlapped: boolean = false
const { mode, app, hoverStore, connectingInput, selectedComponent } =
export let overlapped: string | undefined = undefined
export let moveMode: string | undefined = undefined
export let componentDraggedId: string | undefined = undefined
const { mode, app, hoverStore, connectingInput } =
getContext<AppViewerContext>('AppViewerContext')
const editorContext = getContext<AppEditorContext>('AppEditorContext')
@@ -121,6 +123,36 @@
}
}, 50)
}
function componentDraggedIsNotChild(componentDraggedId: string, componentId: string) {
let parentGrid = findGridItemParentGrid($app, componentDraggedId)
return !parentGrid?.startsWith(`${componentId}-`)
}
function areOnTheSameSubgrid(componentDraggedId: string, componentId: string) {
return (
findGridItemParentGrid($app, componentDraggedId) === findGridItemParentGrid($app, componentId)
)
}
let cachedComponentDraggedIsNotChild: boolean | undefined
let cachedAreOnTheSameSubgrid: boolean | undefined
function updateCache(componentDraggedId: string | undefined) {
if (componentDraggedId) {
cachedComponentDraggedIsNotChild = componentDraggedIsNotChild(
componentDraggedId,
component.id
)
cachedAreOnTheSameSubgrid = areOnTheSameSubgrid(componentDraggedId, component.id)
} else {
cachedComponentDraggedIsNotChild = undefined
cachedAreOnTheSameSubgrid = undefined
}
}
$: updateCache(componentDraggedId)
</script>
<!-- svelte-ignore a11y-mouse-events-have-key-events -->
@@ -139,18 +171,23 @@
hidden && $mode === 'preview' ? 'hidden' : ''
)}
>
{#if locked && componentActive && $componentActive && $selectedComponent?.[0] !== component.id && overlapped}
{#if locked && componentActive && $componentActive && moveMode === 'move' && componentDraggedId && componentDraggedId !== component.id && cachedAreOnTheSameSubgrid}
<div
class={twMerge(
'absolute inset-0 bg-locked center-center flex-col z-50',
overlapped ? 'bg-locked-hover' : ''
)}
class={twMerge('absolute inset-0 bg-locked center-center flex-col z-50', 'bg-locked-hover')}
>
<div class="bg-surface p-2 shadow-sm rounded-md flex center-center flex-col gap-2">
<Anchor size={24} class="text-primary " />
<div class="text-xs">Anchored: The component cannot be moved</div>
<div class="text-xs"> Anchored: The component cannot be moved. </div>
</div>
</div>
{:else if moveMode === 'insert' && isContainer(component.type) && componentDraggedId && componentDraggedId !== component.id && cachedComponentDraggedIsNotChild}
<div
class={twMerge(
'absolute inset-0 flex-col rounded-md bg-blue-100 dark:bg-gray-800 bg-opacity-50',
'outline-dashed outline-offset-2 outline-2 outline-blue-300 dark:outline-blue-700',
overlapped === component?.id ? 'bg-draggedover dark:bg-draggedover-dark' : ''
)}
/>
{/if}
{#if $mode !== 'preview'}
<ComponentHeader
@@ -1,16 +1,44 @@
<script lang="ts" context="module">
import { writable } from 'svelte/store'
const componentDraggedIdStore = writable<string | undefined>(undefined)
const componentDraggedParentIdStore = writable<string | undefined>(undefined)
const overlappedStore = writable<string | undefined>(undefined)
const fakeShadowStore = writable<GridShadow | undefined>(undefined)
const isCtrlOrMetaPressedStore = writable<boolean>(false)
</script>
<script lang="ts">
import gridHelp from './utils/helper'
import type { AppViewerContext, GridItem } from '../types'
import { twMerge } from 'tailwind-merge'
import { getContainerHeight } from './utils/container'
import { moveItem, getItemById, specifyUndefinedColumns } from './utils/item'
import { onMount, createEventDispatcher } from 'svelte'
import { onMount, createEventDispatcher, getContext } from 'svelte'
import { getColumn, throttle } from './utils/other'
import MoveResize from './MoveResize.svelte'
import type { FilledItem } from './types'
import { ROW_GAP_X, ROW_GAP_Y, ROW_HEIGHT, sortGridItemsPosition } from '../editor/appUtils'
import {
areShadowsTheSame,
findGridItemParentGrid,
getDeltaXByComponent,
getDeltaYByComponent,
isContainer,
ROW_GAP_X,
ROW_GAP_Y,
ROW_HEIGHT,
sortGridItemsPosition,
subGridIndexKey,
type GridShadow
} from '../editor/appUtils'
const dispatch = createEventDispatcher()
type T = $$Generic
const { app, worldStore } = getContext<AppViewerContext>('AppViewerContext')
export let items: FilledItem<T>[]
export let rowHeight: number = ROW_HEIGHT
export let cols: [number, number][]
@@ -22,11 +50,10 @@
export let containerWidth: number | undefined = undefined
export let scroller: HTMLElement | undefined = undefined
export let sensor = 20
export let root: boolean = false
export let parentWidth: number | undefined = undefined
let getComputedCols
let container
$: [gapX, gapY] = gap
@@ -81,7 +108,21 @@
let sortedItems: FilledItem<T>[] = []
$: sortedItems = JSON.parse(JSON.stringify(items)).sort((a, b) => a.id.localeCompare(b.id))
let resizing: boolean = false
function handleKeyUp(event) {
if ((event.key === 'Control' || event.key === 'Meta') && $isCtrlOrMetaPressedStore) {
setTimeout(() => {
$isCtrlOrMetaPressedStore = false
$fakeShadowStore = undefined
}, 50)
}
}
const initialFixedStates = new Map()
let initItems: FilledItem<T>[] | undefined = undefined
const updateMatrix = ({ detail }) => {
let isPointerUp = detail.isPointerUp
let citems: FilledItem<T>[]
@@ -114,10 +155,44 @@
...shadows[id]
}
}
let { items, overlap } = moveItem(activeItem, sortedItems, getComputedCols)
sortedItems = items
overlapped = overlap ? id : undefined
if ($isCtrlOrMetaPressedStore) {
if ($componentDraggedParentIdStore === $overlappedStore) {
const fixedContainer = sortedItems.map((item) => {
if (isContainer(item.data['type'])) {
initialFixedStates.set(item.id, {
item3Fixed: item[3].fixed,
item12Fixed: item[12].fixed
})
item[3].fixed = true
item[12].fixed = true
}
return item
})
let { items } = moveItem(activeItem, fixedContainer, getComputedCols)
items = items.map((item) => {
if (initialFixedStates.has(item.id)) {
const initialState = initialFixedStates.get(item.id)
if (initialState) {
item[3].fixed = initialState.item3Fixed
item[12].fixed = initialState.item12Fixed
}
}
return item
})
sortedItems = items
}
} else {
let { items } = moveItem(activeItem, sortedItems, getComputedCols)
sortedItems = items
}
}
}
@@ -139,6 +214,8 @@
//let hiddenComponents = writable({})
let lastDetail: { isPointerUp: false; activate: false; id: string | undefined } | undefined =
undefined
const handleRepaint = ({ detail }) => {
if (!detail.isPointerUp) {
throttleMatrix({ detail })
@@ -156,6 +233,21 @@
*/
}
function handleKeyDown(event) {
if ((event.key === 'Control' || event.key === 'Meta') && !$isCtrlOrMetaPressedStore) {
if (resizing) {
return
}
$isCtrlOrMetaPressedStore = true
if (lastDetail) {
throttleMatrix({ detail: lastDetail })
lastDetail = undefined
}
}
}
let moveResizes: Record<string, MoveResize> = {}
let shadows: Record<string, { x: number; y: number; w: number; h: number } | undefined> = {}
@@ -165,32 +257,199 @@
moveResize?.updateMove(JSON.parse(JSON.stringify(detail.cordDiff)), detail.eventY)
}
})
lastDetail = detail
throttleMatrix({ detail: { isPointerUp: false, activate: false } })
if (!$isCtrlOrMetaPressedStore) {
$overlappedStore = undefined
return
}
if (
// We don't display the fake shadow if the dragged component is a child of the overlapped component
$componentDraggedParentIdStore !== $overlappedStore &&
detail.shadow &&
// only update the fake shadow if the are different
!areShadowsTheSame($fakeShadowStore, detail.shadow)
) {
const draggedItem = sortedItems.find((item) => item.id === $componentDraggedIdStore)
if (draggedItem) {
draggedItem[getComputedCols].x = detail.shadow.x
draggedItem[getComputedCols].y = detail.shadow.y
}
let items: GridItem[] = []
if ($overlappedStore) {
const div = document.getElementById(`component-${$overlappedStore}`)
const type = div?.getAttribute('data-componenttype')
if (!$app.subgrids) {
return
}
const index = type ? subGridIndexKey(type, $overlappedStore, $worldStore) : 0
items = $app.subgrids[`${$overlappedStore}-${index}`] ?? []
} else {
items = $app.grid ?? []
}
if (!draggedItem) {
return
}
const freeSpace = gridHelp.findSpace(draggedItem, items, getComputedCols)
$fakeShadowStore = {
x: freeSpace.x,
y: freeSpace.y,
xPerPx: detail.shadow.xPerPx,
yPerPx: detail.shadow.yPerPx,
w: detail.shadow.w,
h: detail.shadow.h
}
}
// When leaving the overlapped component, we clear the fake shadow
// to avoid rendering it with the wrong position at the next intersection
if (detail.intersectingElement !== $overlappedStore) {
$fakeShadowStore = undefined
}
// Update the overlapped component
$overlappedStore = detail.intersectingElement
}
export function handleInitMove({ detail }) {
export function handleInitMove(id: string) {
$componentDraggedIdStore = id
$componentDraggedParentIdStore = findGridItemParentGrid($app, id)?.split('-')[0] ?? undefined
Object.entries(moveResizes).forEach(([id, moveResize]) => {
if (selectedIds?.includes(id)) {
moveResize?.initmove()
}
})
}
let overlapped: string | undefined = undefined
</script>
<div class="svlt-grid-container" style="height: {containerHeight}px" bind:this={container}>
<svelte:window on:keydown={handleKeyDown} on:keyup={handleKeyUp} />
<div
class="svlt-grid-container"
style="height: {containerHeight}px"
bind:this={container}
id={root ? 'root-grid' : undefined}
data-xperpx={xPerPx}
>
<!-- ROOT SHADOW-->
{#if $isCtrlOrMetaPressedStore && root && $overlappedStore !== $componentDraggedParentIdStore}
<div
class={twMerge(
'absolute inset-0 flex-col rounded-md bg-blue-100 dark:bg-gray-800 bg-opacity-50',
'outline-dashed outline-offset-2 outline-2 outline-blue-300 dark:outline-blue-700',
$componentDraggedIdStore && $overlappedStore === undefined
? 'bg-draggedover dark:bg-draggedover-dark'
: ''
)}
/>
{#if $overlappedStore === undefined && $componentDraggedIdStore && $fakeShadowStore}
{@const columnGap = gapX}
<!-- gap between the columns in px -->
{@const containerBorder = 0.5 * 16}
<!-- 0.5rem converted to px (1rem = 16px) -->
{@const gridTotalWidth = containerWidth ? containerWidth - 2 * containerBorder : 0}
<!-- subtract borders -->
{@const availableWidth = gridTotalWidth - 11 * columnGap}
<!-- subtract gaps between the 12 columns (11 gaps) -->
{@const columnWidthPx = availableWidth / 12}
<!-- divide by the number of columns -->
{@const maxX = Math.floor(availableWidth / columnWidthPx) - $fakeShadowStore.w}
<div class="absolute inset-0">
<div class="relative h-full w-full">
<div
class="absolute bg-blue-300 transition-all"
style={`
left:${Math.min(maxX, $fakeShadowStore.x) * xPerPx + gapX}px ;
top: ${$fakeShadowStore.y * yPerPx + gapY}px;
width: ${$fakeShadowStore.w * xPerPx - gapX}px;
height: ${$fakeShadowStore.h * yPerPx - gapY}px;
`}
/>
</div>
</div>
{/if}
{/if}
{#each sortedItems as item (item.id)}
{#if item[getComputedCols] != undefined}
{#if $isCtrlOrMetaPressedStore && item.id === $overlappedStore && $componentDraggedIdStore && $componentDraggedParentIdStore !== item.id && $fakeShadowStore}
{@const columnGap = gapX}
<!-- gap between the columns in px -->
{@const containerBorder = 0.5 * 16}
<!-- 0.5rem converted to px (1rem = 16px) -->
{@const gridTotalWidth = containerWidth ? containerWidth - 2 * containerBorder : 0}
<!-- subtract borders -->
{@const availableWidth = gridTotalWidth - 11 * columnGap}
<!-- subtract gaps between the 12 columns (11 gaps) -->
{@const columnWidthPx = availableWidth / 12}
<!-- divide by the number of columns -->
{@const maxX = Math.floor(availableWidth / columnWidthPx) - $fakeShadowStore.w}
<div
class="absolute"
style={`
left: ${item[getComputedCols].x * xPerPx + gapX}px;
top: ${item[getComputedCols].y * yPerPx + gapY}px;
`}
>
<div class="relative h-full w-full">
<div
class={twMerge('absolute transition-all duration-[50ms] bg-blue-300')}
style={`
left: calc(${
Math.min($fakeShadowStore.x, maxX) * $fakeShadowStore.xPerPx + gapX
}px + 0.5rem + ${getDeltaXByComponent(item.data['type'])});
top: calc(${
$fakeShadowStore.y * $fakeShadowStore.yPerPx + gapY
}px + 0.5rem + ${getDeltaYByComponent(item.data['type'])});
width: ${$fakeShadowStore.w * $fakeShadowStore.xPerPx - gapX * 2}px;
height: ${$fakeShadowStore.h * $fakeShadowStore.yPerPx - gapY * 2}px;
`}
/>
</div>
</div>
{/if}
<MoveResize
on:initmove={handleInitMove}
on:initmove={() => handleInitMove(item.id)}
on:move={handleMove}
bind:shadow={shadows[item.id]}
bind:this={moveResizes[item.id]}
on:repaint={handleRepaint}
on:resizeStart={() => (resizing = true)}
on:resizeEnd={() => (resizing = false)}
onTop={Boolean(allIdsInPath?.includes(item.id))}
id={item.id}
{xPerPx}
{yPerPx}
fakeShadow={$fakeShadowStore}
on:dropped={(e) => {
$componentDraggedIdStore = undefined
$componentDraggedParentIdStore = undefined
$overlappedStore = undefined
$fakeShadowStore = undefined
lastDetail = undefined
if (!$isCtrlOrMetaPressedStore) {
return
}
dispatch('dropped', e.detail)
}}
width={xPerPx == 0
? 0
: Math.min(getComputedCols, item[getComputedCols] && item[getComputedCols].w) * xPerPx -
@@ -205,10 +464,18 @@
{sensor}
container={scroller}
nativeContainer={container}
{overlapped}
overlapped={$overlappedStore}
moveMode={$isCtrlOrMetaPressedStore ? 'insert' : 'move'}
type={item.data['type']}
>
{#if item[getComputedCols]}
<slot dataItem={item} hidden={false} {overlapped} />
<slot
dataItem={item}
hidden={false}
overlapped={$overlappedStore}
moveMode={$isCtrlOrMetaPressedStore ? 'insert' : 'move'}
componentDraggedId={$componentDraggedIdStore}
/>
{/if}
</MoveResize>
{/if}
@@ -3,6 +3,13 @@
import type { AppEditorContext, AppViewerContext } from '../types'
import { writable } from 'svelte/store'
import { twMerge } from 'tailwind-merge'
import {
computePosition,
findGridItemParentGrid,
isContainer,
type GridShadow
} from '../editor/appUtils'
import { throttle } from './utils/other'
const dispatch = createEventDispatcher()
@@ -28,9 +35,12 @@
export let onTop
export let shadow: { x: number; y: number; w: number; h: number } | undefined = undefined
export let overlapped: string | undefined = undefined
export let moveMode: 'move' | 'insert' = 'move'
export let type: string | undefined = undefined
export let fakeShadow: GridShadow | undefined = undefined
const ctx = getContext<AppEditorContext>('AppEditorContext')
const { mode } = getContext<AppViewerContext>('AppViewerContext')
const { mode, app } = getContext<AppViewerContext>('AppViewerContext')
const scale = ctx ? ctx.scale : writable(100)
@@ -75,6 +85,7 @@
x: (moveX / $scale) * 100 - initX,
y: (moveY / $scale) * 100 - initY
}
dispatch('move', { cordDiff, clientY: clientY })
}
return x
@@ -168,7 +179,7 @@
}
let dragClosure: (() => void) | undefined = undefined
const pointerdown = ({ clientX, clientY, pageX, pageY }) => {
const pointerdown = ({ clientX, clientY }) => {
dragClosure = () => {
dragClosure = undefined
ctx.componentActive.set(true)
@@ -223,16 +234,45 @@
}
}
// Shared state for the shadow position
let currentShadowPosition:
| { x: number; y: number; xPerPx: number; yPerPx: number; h: number; w: number }
| undefined = undefined
let currentIntersectingElementId: string | undefined = undefined
let moving: boolean = false
const pointermove = (event) => {
dragClosure && dragClosure()
event.preventDefault()
event.stopPropagation()
event.stopImmediatePropagation()
moving = true
const { clientX, clientY } = event
const cordDiff = { x: (clientX / $scale) * 100 - initX, y: (clientY / $scale) * 100 - initY }
dispatch('move', { cordDiff, clientY })
if (moveMode === 'move') {
dispatch('move', {
cordDiff,
clientY,
intersectingElement: undefined,
shadow: undefined
})
return
}
throttledComputeShadow(clientX, clientY)
dispatch('move', {
cordDiff,
clientY,
intersectingElement: currentIntersectingElementId,
shadow: currentShadowPosition,
overlapped
})
}
export function updateMove(newCoordDiff, clientY) {
@@ -274,6 +314,56 @@
update()
}
}
let element: HTMLElement | undefined = undefined
function computeShadow(clientX: number, clientY: number) {
const elementsAtPoint = document.elementsFromPoint(clientX, clientY)
const intersectingElement = elementsAtPoint.find(
(el) =>
el.id !== divId &&
el.classList.contains('svlt-grid-item') &&
el.getAttribute('data-iscontainer') === 'true'
)
const newOverlapped = intersectingElement ? intersectingElement?.id.split('-')[1] : undefined
const container = newOverlapped
? intersectingElement?.querySelector('.svlt-grid-container')
: document.getElementById('root-grid')
const xPerPxComputed = Number(container?.getAttribute('data-xperpx')) ?? xPerPx
const position = computePosition(
clientX,
clientY,
xPerPxComputed,
yPerPx,
newOverlapped,
element
)
if (overlapped !== newOverlapped) {
currentShadowPosition = undefined
} else {
// Update shared state
currentShadowPosition = {
x: position.x,
y: position.y,
xPerPx: xPerPxComputed,
yPerPx,
h: item.h,
w: item.w
}
}
currentIntersectingElementId = newOverlapped
}
const throttledComputeShadow = throttle((clientX, clientY) => {
computeShadow(clientX, clientY)
}, 50)
const pointerup = (e) => {
ctx.componentActive.set(false)
stopAutoscroll()
@@ -281,11 +371,29 @@
window.removeEventListener('pointerdown', pointerdown)
window.removeEventListener('pointermove', pointermove)
window.removeEventListener('pointerup', pointerup)
if (!dragClosure) {
repaint(true, true)
} else {
dragClosure = undefined
}
if (!moving) {
return
}
const parent = findGridItemParentGrid($app, id)
if (overlapped && (overlapped === parent || parent?.startsWith(overlapped))) {
return
}
dispatch('dropped', {
id,
overlapped,
x: fakeShadow?.x,
y: fakeShadow?.y
})
}
let resizeInitPos = { x: 0, y: 0 }
@@ -315,6 +423,7 @@
window.addEventListener('pointermove', resizePointerMove)
window.addEventListener('pointerup', resizePointerUp)
dispatch('resizeStart')
}
const resizePointerMove = ({ pageX, pageY }) => {
@@ -350,15 +459,36 @@
window.removeEventListener('pointermove', resizePointerMove)
window.removeEventListener('pointerup', resizePointerUp)
dispatch('resizeEnd')
}
function shouldDisplayShadow(moveMode: 'insert' | 'move', overlapped: string | undefined) {
if (moveMode === 'move') {
return true
}
const parent = findGridItemParentGrid($app, id)
if (parent === undefined) {
return overlapped === undefined
} else if (overlapped) {
return parent.startsWith(overlapped)
}
return false
}
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
bind:this={element}
draggable="false"
on:pointerdown|stopPropagation|preventDefault={pointerdown}
id={divId}
class="svlt-grid-item"
data-iscontainer={type ? isContainer(type) : false}
data-componenttype={type}
class:svlt-grid-active={active || (trans && rect)}
style="width: {xPerPx == 0 ? 0 : active ? newSize.width : width}px; height:{xPerPx == 0
? 0
@@ -377,16 +507,24 @@
} transform: translate(${left}px, ${top}px); `} "
>
<slot />
<div class="svlt-grid-resizer-bottom" on:pointerdown={(e) => resizePointerDown(e, 'vertical')} />
<div class="svlt-grid-resizer-side" on:pointerdown={(e) => resizePointerDown(e, 'horizontal')} />
<div class="svlt-grid-resizer" on:pointerdown={(e) => resizePointerDown(e, 'both')} />
{#if moveMode === 'move'}
<div
class="svlt-grid-resizer-bottom"
on:pointerdown={(e) => resizePointerDown(e, 'vertical')}
/>
<div
class="svlt-grid-resizer-side"
on:pointerdown={(e) => resizePointerDown(e, 'horizontal')}
/>
<div class="svlt-grid-resizer" on:pointerdown={(e) => resizePointerDown(e, 'both')} />
{/if}
</div>
{#if xPerPx > 0 && (active || trans) && shadow}
<div
class={twMerge(
'svlt-grid-shadow shadow-active',
overlapped ? 'svlte-grid-shadow-forbidden' : ''
shouldDisplayShadow(moveMode, overlapped) ? '' : 'hidden'
)}
style="width: {shadow.w * xPerPx - gapX * 2}px; height: {shadow.h * yPerPx -
gapY * 2}px; transform: translate({shadow.x * xPerPx + gapX}px, {shadow.y * yPerPx +
@@ -470,8 +608,4 @@
-webkit-backface-visibility: hidden;
background: #93c4fdd0;
}
.svlte-grid-shadow-forbidden {
background: rgba(255, 99, 71, 0.2) !important;
}
</style>
@@ -31,6 +31,7 @@ function distance(a, b): number {
export function findFreeSpaceForItem<T>(matrix: FilledItem<T>[][], item: ItemLayout) {
const cols = matrix[0].length
const w = Math.min(cols, item.w)
const h = item.h
let xNtime = cols - w + 1
@@ -135,8 +136,7 @@ export function moveItem(active, items, cols) {
// If found fixed, reset the active to its original position
if (fixed) {
return {
items: items,
overlap: closeBlocks.length > 0
items: items
}
}
@@ -178,7 +178,7 @@ export function moveItem(active, items, cols) {
// Return result
return {
items: tempItems,
overlap: false
overlap: undefined
}
}