chromatic-lattice/src/features/boards/Board.vue

262 lines
7.8 KiB
Vue

<template>
<panZoom
v-if="isVisible(visibility)"
v-show="isHidden(visibility)"
:style="[
{
width,
height
},
style
]"
:class="classes"
selector=".g1"
:options="{ initialZoom: 1, minZoom: 0.1, maxZoom: 10, zoomDoubleClickSpeed: 1 }"
ref="stage"
@init="onInit"
@mousemove="drag"
@touchmove="drag"
@mousedown="(e: MouseEvent) => mouseDown(e)"
@touchstart="(e: TouchEvent) => mouseDown(e)"
@mouseup="() => endDragging(dragging)"
@touchend.passive="() => endDragging(dragging)"
@mouseleave="() => endDragging(dragging)"
>
<svg class="stage" width="100%" height="100%">
<g class="g1">
<transition-group name="link" appear>
<g v-for="(link, i) in unref(links) || []" :key="i">
<BoardLinkVue :link="link" />
</g>
</transition-group>
<transition-group name="grow" :duration="500" appear>
<g v-for="node in sortedNodes" :key="node.id" style="transition-duration: 0s">
<BoardNodeVue
:node="node"
:nodeType="types[node.type]"
:dragging="draggingNode"
:dragged="dragged"
:hasDragged="hasDragged"
:receivingNode="receivingNode?.id === node.id"
:selectedNode="unref(selectedNode)"
:selectedAction="unref(selectedAction)"
@mouseDown="mouseDown"
@endDragging="endDragging"
/>
</g>
</transition-group>
</g>
</svg>
</panZoom>
</template>
<script setup lang="ts">
import type {
BoardData,
BoardNode,
BoardNodeLink,
GenericBoardNodeAction,
GenericNodeType
} from "features/boards/board";
import { getNodeProperty } from "features/boards/board";
import type { StyleValue } from "features/feature";
import { isHidden, isVisible, Visibility } from "features/feature";
import type { ProcessedComputable } from "util/computed";
import { computed, ref, Ref, toRefs, unref } from "vue";
import BoardLinkVue from "./BoardLink.vue";
import BoardNodeVue from "./BoardNode.vue";
const _props = defineProps<{
nodes: Ref<BoardNode[]>;
types: Record<string, GenericNodeType>;
state: Ref<BoardData>;
visibility: ProcessedComputable<Visibility | boolean>;
width?: ProcessedComputable<string>;
height?: ProcessedComputable<string>;
style?: ProcessedComputable<StyleValue>;
classes?: ProcessedComputable<Record<string, boolean>>;
links: Ref<BoardNodeLink[] | null>;
selectedAction: Ref<GenericBoardNodeAction | null>;
selectedNode: Ref<BoardNode | null>;
mousePosition: Ref<{ x: number; y: number } | null>;
}>();
const props = toRefs(_props);
const lastMousePosition = ref({ x: 0, y: 0 });
const dragged = ref({ x: 0, y: 0 });
const dragging = ref<number | null>(null);
const hasDragged = ref(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const stage = ref<any>(null);
const draggingNode = computed(() =>
dragging.value == null ? undefined : props.nodes.value.find(node => node.id === dragging.value)
);
const sortedNodes = computed(() => {
const nodes = props.nodes.value.slice();
if (draggingNode.value) {
const node = nodes.splice(nodes.indexOf(draggingNode.value), 1)[0];
nodes.push(node);
}
return nodes;
});
const receivingNode = computed(() => {
const node = draggingNode.value;
if (node == null) {
return null;
}
const position = {
x: node.position.x + dragged.value.x,
y: node.position.y + dragged.value.y
};
let smallestDistance = Number.MAX_VALUE;
return props.nodes.value.reduce((smallest: BoardNode | null, curr: BoardNode) => {
if (curr.id === node.id) {
return smallest;
}
const nodeType = props.types.value[curr.type];
const canAccept = getNodeProperty(nodeType.canAccept, curr);
if (!canAccept) {
return smallest;
}
const distanceSquared =
Math.pow(position.x - curr.position.x, 2) + Math.pow(position.y - curr.position.y, 2);
let size = getNodeProperty(nodeType.size, curr);
if (distanceSquared > smallestDistance || distanceSquared > size * size) {
return smallest;
}
smallestDistance = distanceSquared;
return curr;
}, null);
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function onInit(panzoomInstance: any) {
panzoomInstance.setTransformOrigin(null);
}
function mouseDown(e: MouseEvent | TouchEvent, nodeID: number | null = null, draggable = false) {
if (dragging.value == null) {
e.preventDefault();
e.stopPropagation();
let clientX, clientY;
if ("touches" in e) {
if (e.touches.length === 1) {
clientX = e.touches[0].clientX;
clientY = e.touches[0].clientY;
} else {
return;
}
} else {
clientX = e.clientX;
clientY = e.clientY;
}
lastMousePosition.value = {
x: clientX,
y: clientY
};
dragged.value = { x: 0, y: 0 };
hasDragged.value = false;
if (draggable) {
dragging.value = nodeID;
}
}
if (nodeID != null) {
props.state.value.selectedNode = null;
props.state.value.selectedAction = null;
}
}
function drag(e: MouseEvent | TouchEvent) {
const { x, y, scale } = stage.value.panZoomInstance.getTransform();
let clientX, clientY;
if ("touches" in e) {
if (e.touches.length === 1) {
clientX = e.touches[0].clientX;
clientY = e.touches[0].clientY;
} else {
endDragging(dragging.value);
props.mousePosition.value = null;
return;
}
} else {
clientX = e.clientX;
clientY = e.clientY;
}
props.mousePosition.value = {
x: (clientX - x) / scale,
y: (clientY - y) / scale
};
dragged.value = {
x: dragged.value.x + (clientX - lastMousePosition.value.x) / scale,
y: dragged.value.y + (clientY - lastMousePosition.value.y) / scale
};
lastMousePosition.value = {
x: clientX,
y: clientY
};
if (Math.abs(dragged.value.x) > 10 || Math.abs(dragged.value.y) > 10) {
hasDragged.value = true;
}
if (dragging.value != null) {
e.preventDefault();
e.stopPropagation();
}
}
function endDragging(nodeID: number | null) {
if (dragging.value != null && dragging.value === nodeID && draggingNode.value != null) {
draggingNode.value.position.x += Math.round(dragged.value.x / 25) * 25;
draggingNode.value.position.y += Math.round(dragged.value.y / 25) * 25;
const nodes = props.nodes.value;
nodes.splice(nodes.indexOf(draggingNode.value), 1);
nodes.push(draggingNode.value);
if (receivingNode.value) {
props.types.value[receivingNode.value.type].onDrop?.(
receivingNode.value,
draggingNode.value
);
}
dragging.value = null;
} else if (!hasDragged.value) {
props.state.value.selectedNode = null;
props.state.value.selectedAction = null;
}
}
</script>
<style>
.vue-pan-zoom-scene {
width: 100%;
height: 100%;
cursor: grab;
}
.vue-pan-zoom-scene:active {
cursor: grabbing;
}
.g1 {
transition-duration: 0s;
}
.link-enter-from,
.link-leave-to {
opacity: 0;
}
</style>