Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Autoarrange nodes #695

Merged
merged 7 commits into from
Dec 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions src/ui/src/builder/BuilderHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,27 @@
<div class="undoRedo">
<button
class="undo"
:title="
:data-writer-tooltip="
undoRedoSnapshot.isUndoAvailable
? `Undo ${undoRedoSnapshot.undoDesc}`
? `Undo: ${undoRedoSnapshot.undoDesc}`
: 'Nothing to undo'
"
:disabled="!undoRedoSnapshot.isUndoAvailable"
data-writer-tooltip-placement="bottom"
@click="undo()"
>
<i class="material-symbols-outlined"> undo </i>
Undo
</button>
<button
class="redo"
:title="
:data-writer-tooltip="
undoRedoSnapshot.isRedoAvailable
? `Redo ${undoRedoSnapshot.redoDesc}`
? `Redo: ${undoRedoSnapshot.redoDesc}`
: 'Nothing to redo'
"
:disabled="!undoRedoSnapshot.isRedoAvailable"
data-writer-tooltip-placement="bottom"
@click="redo()"
>
<i class="material-symbols-outlined"> redo </i>
Expand Down
29 changes: 27 additions & 2 deletions src/ui/src/builder/useComponentActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -765,7 +765,7 @@ export function useComponentActions(wf: Core, ssbm: BuilderManager) {
const component = wf.getComponentById(componentId);
if (!component) return;
const transactionId = `edit-${componentId}-out-${out.outId}-${out.toNodeId}`;
ssbm.openMutationTransaction(transactionId, `Edit out`, true);
ssbm.openMutationTransaction(transactionId, "Edit out", true);
ssbm.registerPreMutation(component);

component.outs = component.outs.filter(
Expand All @@ -789,7 +789,7 @@ export function useComponentActions(wf: Core, ssbm: BuilderManager) {
if (!component) return;

const transactionId = `change-${componentId}-coordinates`;
ssbm.openMutationTransaction(transactionId, `Change coordinates`, true);
ssbm.openMutationTransaction(transactionId, "Change coordinates", false);
ssbm.registerPreMutation(component);

component.x = Math.floor(x);
Expand All @@ -800,6 +800,30 @@ export function useComponentActions(wf: Core, ssbm: BuilderManager) {
wf.sendComponentUpdate();
}

/***
* Change the coordinates of multiple components.
*/
function changeCoordinatesMultiple(
coordinates: Record<Component["id"], {x: number, y: number}>
) {
const transactionId = "change-multiple-coordinates";
ssbm.openMutationTransaction(transactionId, "Change coordinates", false);

const entries = Object.entries(coordinates);
if (entries.length == 0) return;
entries.forEach(([componentId, {x, y}]) => {
const component = wf.getComponentById(componentId);
if (!component) return;
ssbm.registerPreMutation(component);
component.x = Math.floor(x);
component.y = Math.floor(y);
ssbm.registerPostMutation(component);
});

ssbm.closeMutationTransaction(transactionId);
wf.sendComponentUpdate();
}

/**
* Set the value for a component's visibility.
*/
Expand Down Expand Up @@ -948,6 +972,7 @@ export function useComponentActions(wf: Core, ssbm: BuilderManager) {
addOut,
removeOut,
changeCoordinates,
changeCoordinatesMultiple,
setVisibleValue,
setBinding,
getUndoRedoSnapshot,
Expand Down
143 changes: 127 additions & 16 deletions src/ui/src/components/workflows/WorkflowsWorkflow.vue
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@
<component
:is="renderProxiedComponent(node.id, 0)"
:style="{
top: `${node.y - renderOffset.y}px`,
left: `${node.x - renderOffset.x}px`,
top: `${(temporaryNodeCoordinates?.[node.id]?.y ?? node.y) - renderOffset.y}px`,
left: `${(temporaryNodeCoordinates?.[node.id]?.x ?? node.x) - renderOffset.x}px`,
'border-color':
activeConnection?.liveArrow?.toNodeId == node.id
? activeConnection?.liveArrow?.color
Expand Down Expand Up @@ -72,6 +72,7 @@
:render-offset="renderOffset"
:zoom-level="zoomLevel"
class="navigator"
@auto-arrange="handleAutoArrange"
@change-render-offset="handleChangeRenderOffset"
@change-zoom-level="handleChangeZoomLevel"
@reset-zoom="resetZoom"
Expand Down Expand Up @@ -154,13 +155,24 @@ const isRunning = ref(false);
const selectedArrow = ref(null);
const zoomLevel = ref(ZOOM_SETTINGS.initialLevel);
const arrowRefresherObserver = new MutationObserver(refreshArrows);
const temporaryNodeCoordinates = ref<
Record<Component["id"], { x: number; y: number }>
>({});

const AUTOARRANGE_ROW_GAP_PX = 96;
const AUTOARRANGE_COLUMN_GAP_PX = 128;

const nodes = computed(() =>
wf.getComponents(workflowComponentId, { sortedByPosition: true }),
);

const { createAndInsertComponent, addOut, removeOut, changeCoordinates } =
useComponentActions(wf, wfbm);
const {
createAndInsertComponent,
addOut,
removeOut,
changeCoordinates,
changeCoordinatesMultiple,
} = useComponentActions(wf, wfbm);
const { getComponentInfoFromDrag } = useDragDropComponent(wf);

const activeConnection: Ref<{
Expand Down Expand Up @@ -214,6 +226,101 @@ function handleClick() {
selectedArrow.value = null;
}

function organizeNodesInColumns() {
const columns: Map<number, Set<Component>> = new Map();

function scan(node: Component, layer: number) {
columns.forEach((column) => {
if (column.has(node)) {
column.delete(node);
}
});
if (!columns.has(layer)) {
columns.set(layer, new Set());
}
const column = columns.get(layer);
column.add(node);
node.outs?.forEach((out) => {
const outNode = wf.getComponentById(out.toNodeId);
scan(outNode, layer + 1);
});
}

const dependencies: Map<Component["id"], Set<Component["id"]>> = new Map();

nodes.value.forEach((node) => {
node.outs?.forEach((outNode) => {
if (!dependencies.has(outNode.toNodeId)) {
dependencies.set(outNode.toNodeId, new Set());
}
dependencies.get(outNode.toNodeId).add(node.id);
});
});

nodes.value
.filter((node) => !dependencies.has(node.id))
.forEach((startNode) => {
scan(startNode, 0);
});

return columns;
}

function calculateAutoArrangeDimensions(columns: Map<number, Set<Component>>) {
const columnDimensions: Map<number, { height: number; width: number }> =
new Map();
const nodeDimensions: Map<Component["id"], { height: number }> = new Map();
columns.forEach((nodes, layer) => {
let height = 0;
let width = 0;
nodes.forEach((node) => {
const nodeEl = nodeContainerEl.value.querySelector(
`[data-writer-id="${node.id}"]`,
);
if (!nodeEl) return;
const nodeBCR = nodeEl.getBoundingClientRect();
nodeDimensions.set(node.id, {
height: nodeBCR.height * (1 / zoomLevel.value),
});
height +=
nodeBCR.height * (1 / zoomLevel.value) + AUTOARRANGE_ROW_GAP_PX;
width = Math.max(width, nodeBCR.width * (1 / zoomLevel.value));
});
columnDimensions.set(layer, {
height: height - AUTOARRANGE_ROW_GAP_PX,
width,
});
});
return { columnDimensions, nodeDimensions };
}

function handleAutoArrange() {
const columns = organizeNodesInColumns();
const { columnDimensions, nodeDimensions } =
calculateAutoArrangeDimensions(columns);
const maxColumnHeight = Math.max(
...Array.from(columnDimensions.values()).map(
(dimensions) => dimensions.height,
),
);

const coordinates = {};
let x = AUTOARRANGE_COLUMN_GAP_PX;
for (let i = 0; i < columns.size; i++) {
const nodes = Array.from(columns.get(i)).sort((a, b) =>
a.y > b.y ? 1 : -1,
);
const { width, height } = columnDimensions.get(i);
let y = (maxColumnHeight - height) / 2 + AUTOARRANGE_ROW_GAP_PX;
nodes.forEach((node) => {
coordinates[node.id] = { x, y };
y += nodeDimensions.get(node.id).height + AUTOARRANGE_ROW_GAP_PX;
});
x += width + AUTOARRANGE_COLUMN_GAP_PX;
}
changeCoordinatesMultiple(coordinates);
}

async function handleRun() {
if (isRunning.value) return;
isRunning.value = true;
Expand Down Expand Up @@ -376,26 +483,27 @@ function clearActiveOperations() {
activeNodeMove.value = null;
}

function saveNodeMove() {
const { nodeId } = activeNodeMove.value;
const tempXY = temporaryNodeCoordinates.value?.[nodeId];
if (!tempXY) return;
const { x, y } = tempXY;
changeCoordinates(nodeId, x, y);
temporaryNodeCoordinates.value[nodeId] = null;
}

function moveNode(ev: MouseEvent) {
const { nodeId, offset } = activeNodeMove.value;
activeNodeMove.value.isPerfected = true;
const component = wf.getComponentById(nodeId);
const { x, y } = getAdjustedCoordinates(ev);

const newX = Math.floor(x - offset.x);
const newY = Math.floor(y - offset.y);

if (component.x == newX && component.y == newY) return;

component.x = newX;
component.y = newY;

setTimeout(() => {
// Debouncing
if (component.x !== newX) return;
if (component.y !== newY) return;
changeCoordinates(component.id, newX, newY);
}, 200);
temporaryNodeCoordinates.value[nodeId] = {
x: newX,
y: newY,
};
}

function moveCanvas(ev: MouseEvent) {
Expand Down Expand Up @@ -444,6 +552,9 @@ function handleMousedown(ev: MouseEvent) {
}

async function handleMouseup(ev: MouseEvent) {
if (activeNodeMove.value) {
saveNodeMove();
}
if (activeConnection.value === null) {
return;
}
Expand Down
4 changes: 2 additions & 2 deletions src/ui/src/components/workflows/base/WorkflowMiniMap.vue
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ const miniMap = ref({
});

const selector = ref({
width: 230,
width: 260,
height: 1,
top: 0,
left: 0,
Expand Down Expand Up @@ -120,7 +120,7 @@ function render() {
});

miniMap.value = {
width: 230,
width: 260,
height:
((nodeContainerBCR.height / nodeContainerBCR.width) *
nodeContainerBCR.width) /
Expand Down
29 changes: 28 additions & 1 deletion src/ui/src/components/workflows/base/WorkflowNavigator.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@
@change-render-offset="handleRenderOffsetChange"
></WorkflowMiniMap>
<div class="bar">
<div class="autoArranger">
<WdsButton
variant="neutral"
size="smallIcon"
data-writer-tooltip="Auto arrange blocks"
data-writer-tooltip-placement="left"
@click="handleAutoArrange"
>
<i class="material-symbols-outlined">apps</i>
</WdsButton>
</div>
<div class="zoomer">
<WdsButton
variant="neutral"
Expand Down Expand Up @@ -99,6 +110,7 @@
"changeRenderOffset",
"changeZoomLevel",
"resetZoom",
"autoArrange",
]);
const isMiniMapShown = ref(true);
const zoomLevelAsText = ref<string>(
Expand All @@ -113,6 +125,10 @@
isMiniMapShown.value = !isMiniMapShown.value;
}

function handleAutoArrange(ev: MouseEvent) {

Check warning on line 128 in src/ui/src/components/workflows/base/WorkflowNavigator.vue

View workflow job for this annotation

GitHub Actions / build (3.9)

'ev' is defined but never used. Allowed unused args must match /^_/u

Check warning on line 128 in src/ui/src/components/workflows/base/WorkflowNavigator.vue

View workflow job for this annotation

GitHub Actions / build (3.9)

'ev' is defined but never used. Allowed unused args must match /^_/u

Check warning on line 128 in src/ui/src/components/workflows/base/WorkflowNavigator.vue

View workflow job for this annotation

GitHub Actions / build (3.9)

'ev' is defined but never used. Allowed unused args must match /^_/u

Check warning on line 128 in src/ui/src/components/workflows/base/WorkflowNavigator.vue

View workflow job for this annotation

GitHub Actions / build (3.10)

'ev' is defined but never used. Allowed unused args must match /^_/u

Check warning on line 128 in src/ui/src/components/workflows/base/WorkflowNavigator.vue

View workflow job for this annotation

GitHub Actions / build (3.10)

'ev' is defined but never used. Allowed unused args must match /^_/u

Check warning on line 128 in src/ui/src/components/workflows/base/WorkflowNavigator.vue

View workflow job for this annotation

GitHub Actions / build (3.10)

'ev' is defined but never used. Allowed unused args must match /^_/u

Check warning on line 128 in src/ui/src/components/workflows/base/WorkflowNavigator.vue

View workflow job for this annotation

GitHub Actions / build (3.11)

'ev' is defined but never used. Allowed unused args must match /^_/u

Check warning on line 128 in src/ui/src/components/workflows/base/WorkflowNavigator.vue

View workflow job for this annotation

GitHub Actions / build (3.11)

'ev' is defined but never used. Allowed unused args must match /^_/u

Check warning on line 128 in src/ui/src/components/workflows/base/WorkflowNavigator.vue

View workflow job for this annotation

GitHub Actions / build (3.11)

'ev' is defined but never used. Allowed unused args must match /^_/u

Check warning on line 128 in src/ui/src/components/workflows/base/WorkflowNavigator.vue

View workflow job for this annotation

GitHub Actions / build (3.12)

'ev' is defined but never used. Allowed unused args must match /^_/u

Check warning on line 128 in src/ui/src/components/workflows/base/WorkflowNavigator.vue

View workflow job for this annotation

GitHub Actions / build (3.12)

'ev' is defined but never used. Allowed unused args must match /^_/u

Check warning on line 128 in src/ui/src/components/workflows/base/WorkflowNavigator.vue

View workflow job for this annotation

GitHub Actions / build (3.12)

'ev' is defined but never used. Allowed unused args must match /^_/u
emit("autoArrange");
}

function handleRenderOffsetChange(offset: typeof props.renderOffset) {
emit("changeRenderOffset", offset);
}
Expand Down Expand Up @@ -190,20 +206,31 @@
overflow: hidden;
color: var(--builderSecondaryTextColor);
border: 1px solid var(--builderSeparatorColor);
width: 230px;
width: 260px;
}

.bar {
display: flex;
background: var(--builderBackgroundColor);
}

.autoArranger {
min-height: 36px;
min-width: 40px;
flex: 0 0 40px;
display: flex;
border-right: 1px solid var(--builderSeparatorColor);
align-items: center;
justify-content: center;
}

.zoomer {
flex: 1 0 auto;
display: flex;
padding: 4px;
gap: 4px;
align-items: center;
justify-content: center;
}

.zoomer .zoomLevelInput {
Expand Down
Loading
Loading