Add animated curly brackets

This commit is contained in:
Andrew Kingston 2024-10-03 09:27:04 +01:00
parent 66f6e91245
commit 4de91d4e3a
No known key found for this signature in database
5 changed files with 131 additions and 89 deletions

View File

@ -0,0 +1,63 @@
<script>
import { BaseEdge } from "@xyflow/svelte"
import { NodeWidth, GridResolution } from "./constants"
import { getContext } from "svelte"
export let sourceX
export let sourceY
const { bounds } = getContext("flow")
const BracketWidth = GridResolution * 2
const BracketHeight = $bounds.height / 2 + GridResolution * 2
$: path = getCurlyBracePath(
sourceX + BracketWidth,
sourceY - BracketHeight,
sourceX + BracketWidth,
sourceY + BracketHeight
)
const getCurlyBracePath = (x1, y1, x2, y2) => {
const w = 2 // Thickness
const q = 2 // Intensity
const i = 28 // Inner radius strenth (lower is stronger)
const j = 32 // Outer radius strength (higher is stronger)
// Calculate unit vector
var dx = x1 - x2
var dy = y1 - y2
var len = Math.sqrt(dx * dx + dy * dy)
dx = dx / len
dy = dy / len
// Path control points
const qx1 = x1 + q * w * dy - j
const qy1 = y1 - q * w * dx
const qx2 = x1 - 0.25 * len * dx + (1 - q) * w * dy - i
const qy2 = y1 - 0.25 * len * dy - (1 - q) * w * dx
const tx1 = x1 - 0.5 * len * dx + w * dy - BracketWidth
const ty1 = y1 - 0.5 * len * dy - w * dx
const qx3 = x2 + q * w * dy - j
const qy3 = y2 - q * w * dx
const qx4 = x1 - 0.75 * len * dx + (1 - q) * w * dy - i
const qy4 = y1 - 0.75 * len * dy - (1 - q) * w * dx
return `M ${x1} ${y1} Q ${qx1} ${qy1} ${qx2} ${qy2} T ${tx1} ${ty1} M ${x2} ${y2} Q ${qx3} ${qy3} ${qx4} ${qy4} T ${tx1} ${ty1}`
}
</script>
<BaseEdge
{...$$props}
{path}
style="--width:{NodeWidth}px; --x:{sourceX}px; --y:{sourceY}px;"
/>
<style>
:global(#basic-bracket) {
animation-timing-function: linear(1, 0);
}
:global(#admin-bracket) {
transform: scale(-1, 1) translateX(calc(var(--width) + 8px));
transform-origin: var(--x) var(--y);
}
</style>

View File

@ -11,11 +11,13 @@
import "@xyflow/svelte/dist/style.css"
import RoleNode from "./RoleNode.svelte"
import RoleEdge from "./RoleEdge.svelte"
import BracketEdge from "./BracketEdge.svelte"
import {
autoLayout,
getAdminPosition,
getBasicPosition,
rolesToLayout,
nodeToRole,
} from "./utils"
import { setContext, tick } from "svelte"
import Controls from "./Controls.svelte"
@ -81,7 +83,7 @@
name: roleId,
uiMetadata: {
displayName: getSequentialName($roles, "New role ", {
getName: x => x.uiMetadata.displayName,
getName: role => role.uiMetadata.displayName,
}),
color: "var(--spectrum-global-color-gray-700)",
description: "Custom role",
@ -108,7 +110,7 @@
if (metadata) {
flow.updateNodeData(roleId, metadata)
}
await roles.save(nodeToRole(node))
await roles.save(nodeToRole({ node, edges: $edges }))
}
const deleteRole = async roleId => {
@ -119,11 +121,11 @@
}
const deleteEdge = async edgeId => {
const edge = $edges.find(x => x.id === edgeId)
const edge = $edges.find(edge => edge.id === edgeId)
if (!edge) {
return
}
edges.set($edges.filter(x => x.id !== edgeId))
edges.set($edges.filter(edge => edge.id !== edgeId))
await updateRole(edge.target)
}
@ -158,7 +160,7 @@
{edges}
snapGrid={[GridResolution, GridResolution]}
nodeTypes={{ role: RoleNode }}
edgeTypes={{ role: RoleEdge }}
edgeTypes={{ role: RoleEdge, bracket: BracketEdge }}
proOptions={{ hideAttribution: true }}
fitViewOptions={{ maxZoom: MaxAutoZoom }}
defaultEdgeOptions={{ type: "role", animated: true, selectable: false }}

View File

@ -8,8 +8,7 @@
ModalContent,
FieldLabel,
} from "@budibase/bbui"
import { Roles } from "constants/backend"
import { NodeWidth, NodeHeight, NodeVSpacing } from "./constants"
import { NodeWidth, NodeHeight } from "./constants"
import { getContext } from "svelte"
import { roles } from "stores/builder"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
@ -70,6 +69,7 @@
<div
class="node"
class:selected
class:custom={data.custom}
style={`--color:${data.color}; --width:${NodeWidth}px; --height:${NodeHeight}px;`}
bind:this={anchor}
>
@ -92,24 +92,14 @@
</div>
{/if}
</div>
{#if isConnectable}
<Handle
type="target"
position={Position.Left}
class={targetClasses}
isConnectable={$dragging}
/>
<Handle type="source" position={Position.Right} />
{/if}
</div>
{#if id === Roles.BASIC || id === Roles.ADMIN}
<div
class="bounds"
class:flip={id === Roles.ADMIN}
style="--height:{$bounds.height}px; --spacing:{NodeVSpacing}px;"
<Handle
type="target"
position={Position.Left}
class={targetClasses}
isConnectable={isConnectable && $dragging}
/>
{/if}
<Handle type="source" position={Position.Right} {isConnectable} />
</div>
<ConfirmDialog
bind:this={deleteModal}
@ -222,7 +212,7 @@
color: var(--spectrum-global-color-gray-600);
}
/* Handlers */
/* Handles */
.node :global(.svelte-flow__handle) {
width: 6px;
height: 6px;
@ -235,34 +225,8 @@
opacity: 0;
pointer-events: none;
}
/* Bounds brackets */
.bounds {
height: calc(var(--height) + 2 * var(--spacing));
position: absolute;
width: calc(var(--spacing) / 1.5);
border: 2px dashed var(--edge-color);
border-right: none;
top: 50%;
left: calc(100% + var(--spacing));
transform: translateY(-50%);
border-top-left-radius: 8px;
border-bottom-left-radius: 8px;
}
.bounds.flip {
left: calc(-1 * var(--spacing));
transform: translateY(-50%) translateX(-100%) scale(-1, 1);
}
.bounds::after {
content: "";
display: block;
position: absolute;
top: 50%;
left: calc(-1 * var(--spacing));
height: 0;
border-top: 2px dashed var(--edge-color);
width: var(--spacing);
box-sizing: border-box;
transform: translateY(-50%);
.node:not(.custom) :global(.svelte-flow__handle) {
visibility: hidden;
pointer-events: none;
}
</style>

View File

@ -3,5 +3,5 @@ export const MaxAutoZoom = 1.2
export const GridResolution = 20
export const NodeHeight = GridResolution * 3
export const NodeWidth = GridResolution * 12
export const NodeHSpacing = GridResolution * 4
export const NodeHSpacing = GridResolution * 6
export const NodeVSpacing = GridResolution * 2

View File

@ -8,19 +8,36 @@ import {
} from "./constants"
import { getNodesBounds, Position } from "@xyflow/svelte"
import { Roles } from "constants/backend"
import { roles } from "stores/builder"
import { get } from "svelte/store"
// Gets the position of the basic role
export const getBasicPosition = bounds => ({
x: bounds.x - NodeHSpacing - NodeWidth,
x: bounds.x - GridResolution * 4 - NodeWidth,
y: bounds.y + bounds.height / 2 - NodeHeight / 2,
})
// Gets the position of the admin role
export const getAdminPosition = bounds => ({
x: bounds.x + bounds.width + NodeHSpacing,
x: bounds.x + bounds.width + GridResolution * 4,
y: bounds.y + bounds.height / 2 - NodeHeight / 2,
})
// Filters out invalid nodes and edges
const preProcessLayout = ({ nodes, edges }) => {
const ignoredRoles = [Roles.PUBLIC, Roles.POWER]
const edglessRoles = [...ignoredRoles, Roles.BASIC, Roles.ADMIN]
return {
nodes: nodes.filter(node => !ignoredRoles.includes(node.id)),
edges: edges.filter(edge => {
return (
!edglessRoles.includes(edge.source) &&
!edglessRoles.includes(edge.target)
)
}),
}
}
// Updates positions of nodes and edges into a nice graph structure
export const dagreLayout = ({ nodes, edges }) => {
const dagreGraph = new dagre.graphlib.Graph()
@ -46,33 +63,34 @@ export const dagreLayout = ({ nodes, edges }) => {
y: Math.round((pos.y - NodeHeight / 2) / GridResolution) * GridResolution,
}
})
return { nodes, edges }
}
const postProcessLayout = ({ nodes, edges }) => {
// Reposition basic and admin to bound the custom nodes
const bounds = getNodesBounds(nodes.filter(node => node.data.custom))
nodes.find(x => x.id === Roles.BASIC).position = getBasicPosition(bounds)
nodes.find(x => x.id === Roles.ADMIN).position = getAdminPosition(bounds)
// Add custom edges for basic and admin brackets
edges.push({
id: "basic-bracket",
source: Roles.BASIC,
target: Roles.ADMIN,
type: "bracket",
})
edges.push({
id: "admin-bracket",
source: Roles.ADMIN,
target: Roles.BASIC,
type: "bracket",
})
return { nodes, edges }
}
// Adds additional edges as needed to the flow structure to ensure compatibility with BB role logic
const sanitiseLayout = ({ nodes, edges }) => {
const ignoredRoles = [Roles.PUBLIC, Roles.POWER]
const edglessRoles = [...ignoredRoles, Roles.BASIC, Roles.ADMIN]
return {
nodes: nodes.filter(node => !ignoredRoles.includes(node.id)),
edges: edges.filter(edge => {
return (
!edglessRoles.includes(edge.source) &&
!edglessRoles.includes(edge.target)
)
}),
}
}
// Automatically lays out the graph, sanitising and enriching the structure
export const autoLayout = ({ nodes, edges }) => {
return dagreLayout(sanitiseLayout({ nodes, edges }))
return postProcessLayout(dagreLayout(preProcessLayout({ nodes, edges })))
}
// Converts a role doc into a node structure
@ -99,27 +117,22 @@ export const roleToNode = role => {
}
// Converts a node structure back into a role doc
export const nodeToRole = node => {
const role = $roles.find(x => x._id === node.id)
const inherits = $edges.filter(x => x.target === node.id).map(x => x.source)
// TODO save inherits array
return {
...role,
inherits,
uiMetadata: {
displayName: node.data.displayName,
color: node.data.color,
description: node.data.description,
},
}
}
export const nodeToRole = ({ node, edges }) => ({
...get(roles).find(role => role._id === node.id),
inherits: edges.filter(x => x.target === node.id).map(x => x.source),
uiMetadata: {
displayName: node.data.displayName,
color: node.data.color,
description: node.data.description,
},
})
// Builds a layout from an array of roles
// Builds a default layout from an array of roles
export const rolesToLayout = roles => {
let nodes = []
let edges = []
// Remove some builtins
// Add all nodes and edges
for (let role of roles) {
// Add node for this role
nodes.push(roleToNode(role))