feat: implement workspaces
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
export { default as useHandle } from './useHandle'
|
||||
export { default as useKeyPress } from './useKeyPress'
|
||||
export { default as useZoomPanHelper } from './useZoomPanHelper'
|
||||
export { default as useWindow } from './useWindow'
|
||||
export { default as useVueFlow } from './useVueFlow'
|
||||
@@ -0,0 +1,224 @@
|
||||
import useVueFlow from './useVueFlow'
|
||||
import { getHostForElement } from '~/utils'
|
||||
import { Connection, ConnectionMode, HandleType, FlowStore, ValidConnectionFunc } from '~/types'
|
||||
|
||||
type Result = {
|
||||
elementBelow: Element | null
|
||||
isValid: boolean
|
||||
connection: Connection
|
||||
isHoveringHandle: boolean
|
||||
}
|
||||
|
||||
// checks if element below mouse is a handle and returns connection in form of an object { source: 123, target: 312 }
|
||||
export const checkElementBelowIsValid = (
|
||||
event: MouseEvent,
|
||||
connectionMode: ConnectionMode,
|
||||
isTarget: boolean,
|
||||
nodeId: string,
|
||||
handleId: string | null,
|
||||
isValidConnection: ValidConnectionFunc,
|
||||
doc: Document,
|
||||
) => {
|
||||
const elementBelow = doc.elementFromPoint(event.clientX, event.clientY)
|
||||
const elementBelowIsTarget = elementBelow?.classList.contains('target') || false
|
||||
const elementBelowIsSource = elementBelow?.classList.contains('source') || false
|
||||
|
||||
const result: Result = {
|
||||
elementBelow,
|
||||
isValid: false,
|
||||
connection: { source: null, target: null, sourceHandle: null, targetHandle: null },
|
||||
isHoveringHandle: false,
|
||||
}
|
||||
|
||||
if (elementBelow && (elementBelowIsTarget || elementBelowIsSource)) {
|
||||
result.isHoveringHandle = true
|
||||
|
||||
// in strict mode we don't allow target to target or source to source connections
|
||||
const isValid =
|
||||
connectionMode === ConnectionMode.Strict ? (isTarget && elementBelowIsSource) || (!isTarget && elementBelowIsTarget) : true
|
||||
|
||||
if (isValid) {
|
||||
const elementBelowNodeId = elementBelow.getAttribute('data-nodeid') ?? ''
|
||||
const elementBelowHandleId = elementBelow.getAttribute('data-handleid') ?? ''
|
||||
const connection: Connection = isTarget
|
||||
? {
|
||||
source: elementBelowNodeId,
|
||||
sourceHandle: elementBelowHandleId,
|
||||
target: nodeId,
|
||||
targetHandle: handleId,
|
||||
}
|
||||
: {
|
||||
source: nodeId,
|
||||
sourceHandle: handleId,
|
||||
target: elementBelowNodeId,
|
||||
targetHandle: elementBelowHandleId,
|
||||
}
|
||||
|
||||
result.connection = connection
|
||||
result.isValid = isValidConnection(connection)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const resetRecentHandle = (hoveredHandle: Element): void => {
|
||||
hoveredHandle?.classList.remove('vue-flow__handle-valid')
|
||||
hoveredHandle?.classList.remove('vue-flow__handle-connecting')
|
||||
}
|
||||
|
||||
export default (store: FlowStore = useVueFlow().store) => {
|
||||
let recentHoveredHandle: Element
|
||||
|
||||
const onMouseDown = (
|
||||
event: MouseEvent,
|
||||
handleId: string | null,
|
||||
nodeId: string,
|
||||
isTarget: boolean,
|
||||
isValidConnection?: ValidConnectionFunc,
|
||||
elementEdgeUpdaterType?: HandleType,
|
||||
onEdgeUpdate?: (connection: Connection) => void,
|
||||
onEdgeUpdateEnd?: () => void,
|
||||
) => {
|
||||
const flowNode = (event.target as Element).closest('.vue-flow')
|
||||
const doc = getHostForElement(event.target as HTMLElement)
|
||||
if (!doc) return
|
||||
|
||||
let validConnectFunc: ValidConnectionFunc = isValidConnection ?? (() => true)
|
||||
const node = store.getNode(nodeId)
|
||||
|
||||
if (node && (typeof node.connectable === 'undefined' ? store.nodesConnectable : node.connectable) === false) return
|
||||
if (!isValidConnection) {
|
||||
if (node) validConnectFunc = (!isTarget ? node.isValidTargetPos : node.isValidSourcePos) ?? (() => true)
|
||||
}
|
||||
const elementBelow = doc.elementFromPoint(event.clientX, event.clientY)
|
||||
const elementBelowIsTarget = elementBelow?.classList.contains('target')
|
||||
const elementBelowIsSource = elementBelow?.classList.contains('source')
|
||||
|
||||
if (!flowNode || (!elementBelowIsTarget && !elementBelowIsSource && !elementEdgeUpdaterType)) return
|
||||
|
||||
const handleType = elementEdgeUpdaterType ?? (elementBelowIsTarget ? 'target' : 'source')
|
||||
const containerBounds = flowNode.getBoundingClientRect()
|
||||
|
||||
store.setState({
|
||||
connectionPosition: {
|
||||
x: event.clientX - containerBounds.left,
|
||||
y: event.clientY - containerBounds.top,
|
||||
},
|
||||
connectionNodeId: nodeId,
|
||||
connectionHandleId: handleId,
|
||||
connectionHandleType: handleType,
|
||||
})
|
||||
|
||||
store.hooks.connectStart.trigger({ event, nodeId, handleId, handleType })
|
||||
|
||||
function onMouseMove(event: MouseEvent) {
|
||||
store.connectionPosition.x = event.clientX - containerBounds.left
|
||||
store.connectionPosition.y = event.clientY - containerBounds.top
|
||||
|
||||
const { connection, elementBelow, isValid, isHoveringHandle } = checkElementBelowIsValid(
|
||||
event,
|
||||
store.connectionMode,
|
||||
isTarget,
|
||||
nodeId,
|
||||
handleId,
|
||||
validConnectFunc,
|
||||
doc,
|
||||
)
|
||||
|
||||
if (!isHoveringHandle) return resetRecentHandle(recentHoveredHandle)
|
||||
|
||||
const isOwnHandle = connection.source === connection.target
|
||||
|
||||
if (!isOwnHandle && elementBelow) {
|
||||
recentHoveredHandle = elementBelow
|
||||
elementBelow.classList.add('vue-flow__handle-connecting')
|
||||
elementBelow.classList.toggle('vue-flow__handle-valid', isValid)
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseUp(event: MouseEvent) {
|
||||
const { connection, isValid } = checkElementBelowIsValid(
|
||||
event,
|
||||
store.connectionMode,
|
||||
isTarget,
|
||||
nodeId,
|
||||
handleId,
|
||||
validConnectFunc,
|
||||
doc,
|
||||
)
|
||||
|
||||
store.hooks.connectStop.trigger(event)
|
||||
const isOwnHandle = connection.source === connection.target
|
||||
|
||||
if (isValid && !isOwnHandle) {
|
||||
onEdgeUpdate?.(connection)
|
||||
}
|
||||
|
||||
store.hooks.connectEnd.trigger(event)
|
||||
|
||||
if (elementEdgeUpdaterType) onEdgeUpdateEnd?.()
|
||||
|
||||
resetRecentHandle(recentHoveredHandle)
|
||||
store.setState({
|
||||
connectionNodeId: null,
|
||||
connectionHandleId: null,
|
||||
connectionHandleType: null,
|
||||
connectionPosition: { x: NaN, y: NaN },
|
||||
})
|
||||
|
||||
doc.removeEventListener('mousemove', onMouseMove as EventListenerOrEventListenerObject)
|
||||
doc.removeEventListener('mouseup', onMouseUp as EventListenerOrEventListenerObject)
|
||||
}
|
||||
|
||||
doc.addEventListener('mousemove', onMouseMove as EventListenerOrEventListenerObject)
|
||||
doc.addEventListener('mouseup', onMouseUp as EventListenerOrEventListenerObject)
|
||||
}
|
||||
|
||||
const onClick = (
|
||||
event: MouseEvent,
|
||||
handleId: string | null,
|
||||
nodeId: string,
|
||||
handleType: HandleType,
|
||||
isValidConnection?: ValidConnectionFunc,
|
||||
) => {
|
||||
if (!store.connectOnClick) return
|
||||
if (!store.connectionStartHandle) {
|
||||
store.hooks.connectStart.trigger({ event, nodeId, handleId, handleType })
|
||||
store.setState({ connectionStartHandle: { nodeId, type: handleType, handleId } })
|
||||
} else {
|
||||
let validConnectFunc: ValidConnectionFunc = isValidConnection ?? (() => true)
|
||||
const node = store.getNode(nodeId)
|
||||
|
||||
if (node && (typeof node.connectable === 'undefined' ? store.nodesConnectable : node.connectable) === false) return
|
||||
if (!isValidConnection) {
|
||||
if (node) validConnectFunc = (handleType !== 'target' ? node.isValidTargetPos : node.isValidSourcePos) ?? (() => true)
|
||||
}
|
||||
const doc = getHostForElement(event.target as HTMLElement)
|
||||
const { connection, isValid } = checkElementBelowIsValid(
|
||||
event as MouseEvent,
|
||||
store.connectionMode,
|
||||
store.connectionStartHandle.type === 'target',
|
||||
store.connectionStartHandle.nodeId,
|
||||
store.connectionStartHandle.handleId || null,
|
||||
validConnectFunc,
|
||||
doc,
|
||||
)
|
||||
|
||||
const isOwnHandle = connection.source === connection.target
|
||||
|
||||
store.hooks.connectStop.trigger(event)
|
||||
|
||||
if (isValid && !isOwnHandle) store.hooks.connect.trigger(connection)
|
||||
|
||||
store.hooks.connectEnd.trigger(event)
|
||||
|
||||
store.setState({ connectionStartHandle: null })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
onMouseDown,
|
||||
onClick,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Ref } from 'vue'
|
||||
import { onKeyDown, onKeyPressed, onKeyUp } from '@vueuse/core'
|
||||
import useWindow from './useWindow'
|
||||
import { KeyCode } from '~/types'
|
||||
import { isInputDOMNode } from '~/utils'
|
||||
|
||||
export default (keyCode: KeyCode, onChange?: (keyPressed: boolean) => void): Ref<boolean> => {
|
||||
const window = useWindow()
|
||||
const isPressed = controlledRef<boolean>(false, {
|
||||
onBeforeChange(val, oldVal) {
|
||||
if (val === oldVal) return false
|
||||
},
|
||||
onChanged() {
|
||||
if (onChange && typeof onChange === 'function') onChange(isPressed.value)
|
||||
},
|
||||
})
|
||||
|
||||
onKeyPressed(
|
||||
(e) => !isInputDOMNode(e) && (e.key === keyCode || e.keyCode === keyCode),
|
||||
(e) => {
|
||||
e.preventDefault()
|
||||
isPressed.value = true
|
||||
},
|
||||
)
|
||||
|
||||
onKeyDown(
|
||||
(e) => !isInputDOMNode(e) && (e.key === keyCode || e.keyCode === keyCode),
|
||||
(e) => {
|
||||
e.preventDefault()
|
||||
isPressed.value = true
|
||||
},
|
||||
)
|
||||
|
||||
onKeyUp(
|
||||
(e) => !isInputDOMNode(e) && (e.key === keyCode || e.keyCode === keyCode),
|
||||
(e) => {
|
||||
e.preventDefault()
|
||||
isPressed.value = false
|
||||
},
|
||||
)
|
||||
|
||||
if (typeof window.addEventListener !== 'undefined') {
|
||||
useEventListener(window, 'blur', () => {
|
||||
isPressed.value = false
|
||||
})
|
||||
}
|
||||
|
||||
if (onChange && typeof onChange === 'function') onChange(isPressed.value)
|
||||
|
||||
return isPressed
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { EffectScope } from 'vue'
|
||||
import { FlowOptions, UseVueFlow } from '~/types'
|
||||
import { VueFlow } from '~/context'
|
||||
import { useStore } from '~/store'
|
||||
|
||||
export class Storage {
|
||||
public currentId = 0
|
||||
public flows = new Map<string, UseVueFlow>()
|
||||
static instance: Storage
|
||||
|
||||
public static getInstance(): Storage {
|
||||
if (!Storage.instance) {
|
||||
Storage.instance = new Storage()
|
||||
}
|
||||
|
||||
return Storage.instance
|
||||
}
|
||||
|
||||
public set(id: string, flow: UseVueFlow) {
|
||||
this.flows.set(id, flow)
|
||||
}
|
||||
|
||||
public get(id: string) {
|
||||
return this.flows.get(id)
|
||||
}
|
||||
|
||||
public remove(id: string) {
|
||||
this.flows.delete(id)
|
||||
}
|
||||
|
||||
public create(id: string, options?: Partial<FlowOptions>) {
|
||||
const store = useStore(options)
|
||||
const flow = {
|
||||
id,
|
||||
store: reactive(store),
|
||||
...toRefs(store.state),
|
||||
...store.getters,
|
||||
...store.actions,
|
||||
...store.hooksOn,
|
||||
} as unknown as UseVueFlow
|
||||
this.set(id, flow)
|
||||
return flow
|
||||
}
|
||||
|
||||
public getId() {
|
||||
return `vue-flow-${this.currentId++}`
|
||||
}
|
||||
}
|
||||
|
||||
type Injection = UseVueFlow | null | undefined
|
||||
type Scope = (EffectScope & { vueFlowId: string }) | undefined
|
||||
|
||||
export default <N = any, E = N>(options?: Partial<FlowOptions<N, E>>): UseVueFlow<N, E> => {
|
||||
const storage = Storage.getInstance()
|
||||
const scope = getCurrentScope() as Scope
|
||||
const vueFlowId = scope?.vueFlowId || options?.id
|
||||
|
||||
let vueFlow: Injection
|
||||
|
||||
if (scope) {
|
||||
const injection = inject(VueFlow, null)
|
||||
if (typeof injection !== 'undefined' && injection !== null) vueFlow = injection
|
||||
}
|
||||
|
||||
if (!vueFlow) {
|
||||
if (vueFlowId) vueFlow = storage.get(vueFlowId)
|
||||
}
|
||||
|
||||
if (!vueFlow || (vueFlow && options?.id && options.id !== vueFlow.id)) {
|
||||
const name = options?.id ?? storage.getId()
|
||||
|
||||
vueFlow = storage.create(name, options)
|
||||
|
||||
if (scope) {
|
||||
provide(VueFlow, storage.get(name))
|
||||
scope.vueFlowId = name
|
||||
|
||||
onScopeDispose(() => {
|
||||
storage.remove(name)
|
||||
vueFlow = null
|
||||
})
|
||||
}
|
||||
} else {
|
||||
if (options) vueFlow.setState(options)
|
||||
}
|
||||
|
||||
if (!vueFlow) throw new Error('vue flow store instance not found.')
|
||||
|
||||
return vueFlow
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
type UseWindow = Window & typeof globalThis & { chrome?: any }
|
||||
|
||||
export default (): UseWindow => {
|
||||
if (typeof window !== 'undefined') return window as UseWindow
|
||||
else return { chrome: false } as UseWindow
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { zoomIdentity } from 'd3-zoom'
|
||||
import useVueFlow from './useVueFlow'
|
||||
import useWindow from './useWindow'
|
||||
import { getRectOfNodes, pointToRendererPoint, getTransformForBounds } from '~/utils'
|
||||
import { GraphNode, FlowStore, UseZoomPanHelper, D3Selection } from '~/types'
|
||||
|
||||
const DEFAULT_PADDING = 0.1
|
||||
|
||||
const transition = (selection: D3Selection, ms = 0) => selection.transition().duration(ms)
|
||||
|
||||
export default (store: FlowStore = useVueFlow().store): UseZoomPanHelper => ({
|
||||
zoomIn: (options) => store.d3Selection && store.d3Zoom?.scaleBy(transition(store.d3Selection, options?.duration), 1.2),
|
||||
zoomOut: (options) => store.d3Selection && store.d3Zoom?.scaleBy(transition(store.d3Selection, options?.duration), 1 / 1.2),
|
||||
zoomTo: (zoomLevel, options) =>
|
||||
store.d3Selection && store.d3Zoom?.scaleTo(transition(store.d3Selection, options?.duration), zoomLevel),
|
||||
setTransform: (transform, options) => {
|
||||
const nextTransform = zoomIdentity.translate(transform.x, transform.y).scale(transform.zoom)
|
||||
store.d3Selection && store.d3Zoom?.transform(transition(store.d3Selection, options?.duration), nextTransform)
|
||||
},
|
||||
getTransform: () => ({
|
||||
x: store.transform[0],
|
||||
y: store.transform[1],
|
||||
zoom: store.transform[2],
|
||||
}),
|
||||
fitView: async (
|
||||
options = {
|
||||
padding: DEFAULT_PADDING,
|
||||
includeHiddenNodes: false,
|
||||
duration: 0,
|
||||
},
|
||||
) => {
|
||||
// if ssr we can't wait for dimensions, they'll never really exist
|
||||
const window = useWindow()
|
||||
if ('screen' in window)
|
||||
await until(store.dimensions).toMatch(({ height, width }) => !isNaN(width) && width > 0 && !isNaN(height) && height > 0)
|
||||
|
||||
if (!store.getNodes.length) return
|
||||
|
||||
let nodes: GraphNode[] = []
|
||||
if (options.nodes) {
|
||||
nodes = store.nodes.filter((n) => options.nodes?.includes(n.id))
|
||||
}
|
||||
if (!nodes || !nodes.length) {
|
||||
nodes = options.includeHiddenNodes ? store.nodes : store.getNodes
|
||||
}
|
||||
const bounds = getRectOfNodes(nodes)
|
||||
const [x, y, zoom] = getTransformForBounds(
|
||||
bounds,
|
||||
store.dimensions.width,
|
||||
store.dimensions.height,
|
||||
options.minZoom ?? store.minZoom,
|
||||
options.maxZoom ?? store.maxZoom,
|
||||
options.padding ?? DEFAULT_PADDING,
|
||||
options.offset,
|
||||
)
|
||||
const transform = zoomIdentity.translate(x, y).scale(zoom)
|
||||
|
||||
store.d3Selection && store.d3Zoom?.transform(transition(store.d3Selection, options?.duration), transform)
|
||||
},
|
||||
setCenter: (x, y, options) => {
|
||||
const nextZoom = typeof options?.zoom !== 'undefined' ? options.zoom : store.maxZoom
|
||||
const centerX = store.dimensions.width / 2 - x * nextZoom
|
||||
const centerY = store.dimensions.height / 2 - y * nextZoom
|
||||
const transform = zoomIdentity.translate(centerX, centerY).scale(nextZoom)
|
||||
|
||||
store.d3Selection && store.d3Zoom?.transform(transition(store.d3Selection, options?.duration), transform)
|
||||
},
|
||||
fitBounds: (bounds, options = { padding: DEFAULT_PADDING }) => {
|
||||
const [x, y, zoom] = getTransformForBounds(
|
||||
bounds,
|
||||
store.dimensions.width,
|
||||
store.dimensions.height,
|
||||
store.minZoom,
|
||||
store.maxZoom,
|
||||
options.padding,
|
||||
)
|
||||
const transform = zoomIdentity.translate(x, y).scale(zoom)
|
||||
|
||||
store.d3Selection && store.d3Zoom?.transform(transition(store.d3Selection, options.duration), transform)
|
||||
},
|
||||
project: (position) => pointToRendererPoint(position, store.transform, store.snapToGrid, store.snapGrid),
|
||||
})
|
||||
Reference in New Issue
Block a user