Skip to content

Commit 9cda4af

Browse files
committed
feat: allow dragging participants to create new messages
1 parent 4c46879 commit 9cda4af

File tree

5 files changed

+150
-41
lines changed

5 files changed

+150
-41
lines changed
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { useDocumentEvent } from "@/functions/useDocumentEvent";
2+
import { dragParticipantAtom } from "@/store/Store";
3+
import { useAtom } from "jotai";
4+
import { useRef, useState } from "react";
5+
6+
export const DragLine = () => {
7+
const [position, setPosition] = useState([0, 0]);
8+
const [dragParticipant, setDragParticipant] = useAtom(dragParticipantAtom);
9+
const elRef = useRef<SVGSVGElement>(null);
10+
11+
useDocumentEvent("mousemove", (e) => {
12+
const diagramRect = elRef.current?.getBoundingClientRect();
13+
if (!diagramRect) return;
14+
if (dragParticipant) {
15+
setPosition([e.clientX - diagramRect.left, e.clientY - diagramRect.top]);
16+
}
17+
});
18+
useDocumentEvent("mouseup", () => {
19+
if (dragParticipant) {
20+
setDragParticipant(undefined);
21+
setPosition([0, 0]);
22+
}
23+
});
24+
25+
if (!dragParticipant) return null;
26+
return (
27+
<svg
28+
className="absolute top-0 left-0 w-full h-full pointer-events-none"
29+
ref={elRef}
30+
>
31+
<defs>
32+
<marker
33+
id="arrowhead"
34+
markerWidth="6"
35+
markerHeight="7"
36+
refX="4"
37+
refY="3.5"
38+
orient="auto"
39+
>
40+
<polygon points="0 0, 6 3.5, 0 7" fill="#0094D9" />
41+
</marker>
42+
</defs>
43+
<line
44+
x1={dragParticipant.x}
45+
y1={dragParticipant.y}
46+
x2={position[0] || dragParticipant.x}
47+
y2={position[1] || dragParticipant.y}
48+
stroke="#0094D9"
49+
strokeWidth="2"
50+
markerEnd="url(#arrowhead)"
51+
strokeDasharray="6,4"
52+
/>
53+
</svg>
54+
);
55+
};

src/components/DiagramFrame/SeqDiagram/LifeLineLayer/Participant.tsx

Lines changed: 85 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@ import useIntersectionTop from "@/functions/useIntersectionTop";
33
import { _STARTER_ } from "@/parser/OrderedParticipants";
44
import { PARTICIPANT_HEIGHT } from "@/positioning/Constants";
55
import {
6+
codeAtom,
67
diagramElementAtom,
8+
dragParticipantAtom,
79
modeAtom,
10+
onContentChangeAtom,
811
onSelectAtom,
912
participantsAtom,
1013
RenderMode,
@@ -14,18 +17,21 @@ import {
1417
import { cn } from "@/utils";
1518
import { brightnessIgnoreAlpha, removeAlpha } from "@/utils/Color";
1619
import { getElementDistanceToTop } from "@/utils/dom";
17-
import { useAtomValue, useSetAtom } from "jotai";
20+
import { useAtom, useAtomValue, useSetAtom } from "jotai";
1821
import { useMemo, useRef } from "react";
1922
import { ParticipantLabel } from "./ParticipantLabel";
2023
import iconPath from "../../Tutorial/Icons";
2124

2225
const INTERSECTION_ERROR_MARGIN = 10;
2326

27+
const formatName = (name: string) => (name.includes(" ") ? `"${name}"` : name);
28+
2429
export const Participant = (props: {
2530
entity: Record<string, string>;
2631
offsetTop2?: number;
2732
}) => {
2833
const elRef = useRef<HTMLDivElement>(null);
34+
const [code, setCode] = useAtom(codeAtom);
2935
const mode = useAtomValue(modeAtom);
3036
const participants = useAtomValue(participantsAtom);
3137
const diagramElement = useAtomValue(diagramElementAtom);
@@ -34,6 +40,8 @@ export const Participant = (props: {
3440
const onSelect = useSetAtom(onSelectAtom);
3541
const intersectionTop = useIntersectionTop();
3642
const [scrollTop] = useDocumentScroll();
43+
const [dragParticipant, setDragParticipant] = useAtom(dragParticipantAtom);
44+
const onContentChange = useAtomValue(onContentChangeAtom);
3745

3846
const isDefaultStarter = props.entity.name === _STARTER_;
3947

@@ -89,51 +97,89 @@ export const Participant = (props: {
8997
? iconPath["actor"]
9098
: iconPath[props.entity.type?.toLowerCase() as "actor"];
9199

100+
const handleDrag = () => {
101+
const {
102+
left = 0,
103+
top = 0,
104+
width = 0,
105+
height = 0,
106+
} = elRef.current?.getBoundingClientRect() || {};
107+
const diagramRect = diagramElement?.getBoundingClientRect();
108+
setDragParticipant({
109+
name: props.entity.name,
110+
x: left + width / 2 - (diagramRect?.left || 0),
111+
y: top + height / 2 - (diagramRect?.top || 0),
112+
});
113+
};
114+
const handleDrop = () => {
115+
if (dragParticipant && dragParticipant.name !== props.entity.name) {
116+
const isFromStarter = dragParticipant.name === _STARTER_;
117+
const newCode =
118+
code +
119+
(isFromStarter
120+
? `\n${formatName(props.entity.name)}.message`
121+
: `\n${formatName(dragParticipant.name)} -> ${formatName(props.entity.name)}.message`);
122+
setCode(newCode);
123+
onContentChange(newCode);
124+
}
125+
};
126+
92127
return (
93128
<div
94129
className={cn(
95-
"participant bg-skin-participant shadow-participant border-skin-participant text-skin-participant rounded text-base leading-4 flex flex-col justify-center z-10 h-10 top-8",
96-
{ selected: selected.includes(props.entity.name) },
130+
"hover:shadow-[0_0_3px_2px_#0094D988] hover:shadow-participant-hover transition-shadow duration-200 cursor-pointer rounded",
131+
dragParticipant &&
132+
dragParticipant.name !== props.entity.name &&
133+
"cursor-copy",
97134
)}
98-
ref={elRef}
99-
style={{
100-
backgroundColor: isDefaultStarter ? undefined : backgroundColor,
101-
color: isDefaultStarter ? undefined : color,
102-
transform: `translateY(${stickyVerticalOffset}px)`,
103-
}}
104-
onClick={() => onSelect(props.entity.name)}
105-
data-participant-id={props.entity.name}
106135
>
107-
<div className="flex items-center justify-center">
108-
{icon && (
109-
<div
110-
className="h-6 w-6 mr-1 flex-shrink-0 [&>svg]:w-full [&>svg]:h-full"
111-
aria-description={`icon for ${props.entity.name}`}
112-
dangerouslySetInnerHTML={{
113-
__html: icon,
114-
}}
115-
/>
136+
<div
137+
className={cn(
138+
"participant bg-skin-participant shadow-participant border-skin-participant text-skin-participant rounded text-base leading-4 flex flex-col justify-center z-10 h-10 top-8",
139+
{ selected: selected.includes(props.entity.name) },
116140
)}
117-
118-
{!isDefaultStarter && (
119-
<div className="h-5 group flex flex-col justify-center">
120-
{props.entity.stereotype && (
121-
<label className="interface leading-4">
122-
«{props.entity.stereotype}»
123-
</label>
124-
)}
125-
<ParticipantLabel
126-
labelText={
127-
props.entity.assignee
128-
? props.entity.name.split(":")[1]
129-
: props.entity.label || props.entity.name
130-
}
131-
labelPositions={labelPositions}
132-
assignee={props.entity.assignee}
133-
assigneePositions={assigneePositions}
141+
ref={elRef}
142+
style={{
143+
backgroundColor: isDefaultStarter ? undefined : backgroundColor,
144+
color: isDefaultStarter ? undefined : color,
145+
transform: `translateY(${stickyVerticalOffset}px)`,
146+
}}
147+
onClick={() => onSelect(props.entity.name)}
148+
data-participant-id={props.entity.name}
149+
onMouseDown={handleDrag}
150+
onMouseUp={handleDrop}
151+
>
152+
<div className="flex items-center justify-center">
153+
{icon && (
154+
<div
155+
className="h-6 w-6 mr-1 flex-shrink-0 [&>svg]:w-full [&>svg]:h-full"
156+
aria-description={`icon for ${props.entity.name}`}
157+
dangerouslySetInnerHTML={{
158+
__html: icon,
159+
}}
134160
/>
135-
</div>
136-
)}
161+
)}
162+
163+
{!isDefaultStarter && (
164+
<div className="h-5 group flex flex-col justify-center">
165+
{props.entity.stereotype && (
166+
<label className="interface leading-4">
167+
«{props.entity.stereotype}»
168+
</label>
169+
)}
170+
<ParticipantLabel
171+
labelText={
172+
props.entity.assignee
173+
? props.entity.name.split(":")[1]
174+
: props.entity.label || props.entity.name
175+
}
176+
labelPositions={labelPositions}
177+
assignee={props.entity.assignee}
178+
assigneePositions={assigneePositions}
179+
/>
180+
</div>
181+
)}
182+
</div>
137183
</div>
138184
</div>
139185
);

src/components/DiagramFrame/SeqDiagram/MessageLayer/MessageLabel.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export const MessageLabel = (props: {
1717
const mode = useAtomValue(modeAtom);
1818
const [code, setCode] = useAtom(codeAtom);
1919
const onContentChange = useAtomValue(onContentChangeAtom);
20-
const formattedLabelText = formatText(props.labelText);
20+
const formattedLabelText = formatText(props.labelText || "");
2121

2222
const replaceLabelText = (e: FocusEvent | KeyboardEvent | MouseEvent) => {
2323
e.preventDefault();

src/components/DiagramFrame/SeqDiagram/SeqDiagram.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import "./SeqDiagram.css";
2121
import { cn } from "@/utils";
2222
import { LifeLineLayer } from "./LifeLineLayer/LifeLineLayer";
2323
import { MessageLayer } from "./MessageLayer/MessageLayer";
24+
import { DragLine } from "./DragLine";
2425

2526
export const SeqDiagram = (props: {
2627
className?: string;
@@ -60,7 +61,7 @@ export const SeqDiagram = (props: {
6061
return (
6162
<div
6263
className={cn(
63-
"zenuml sequence-diagram relative box-border text-left overflow-visible px-2.5",
64+
"zenuml sequence-diagram relative box-border text-left overflow-visible px-2.5 select-none",
6465
theme,
6566
props.className,
6667
)}
@@ -108,6 +109,7 @@ export const SeqDiagram = (props: {
108109
</>
109110
)}
110111
</div>
112+
<DragLine />
111113
</div>
112114
);
113115
};

src/store/Store.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,4 +104,10 @@ export const onEventEmitAtom = atomWithFunctionValue<
104104
(name: string, data: any) => void
105105
>(() => {});
106106

107+
export const dragParticipantAtom = atom<{
108+
name: string;
109+
x: number;
110+
y: number;
111+
}>();
112+
107113
export default store;

0 commit comments

Comments
 (0)