From 9cda4af0765c85e6022161ec85c28406534f5675 Mon Sep 17 00:00:00 2001 From: dyon21 Date: Mon, 30 Jun 2025 01:06:40 +0800 Subject: [PATCH 1/5] feat: allow dragging participants to create new messages --- .../DiagramFrame/SeqDiagram/DragLine.tsx | 55 ++++++++ .../SeqDiagram/LifeLineLayer/Participant.tsx | 124 ++++++++++++------ .../SeqDiagram/MessageLayer/MessageLabel.tsx | 2 +- .../DiagramFrame/SeqDiagram/SeqDiagram.tsx | 4 +- src/store/Store.ts | 6 + 5 files changed, 150 insertions(+), 41 deletions(-) create mode 100644 src/components/DiagramFrame/SeqDiagram/DragLine.tsx diff --git a/src/components/DiagramFrame/SeqDiagram/DragLine.tsx b/src/components/DiagramFrame/SeqDiagram/DragLine.tsx new file mode 100644 index 00000000..4a373809 --- /dev/null +++ b/src/components/DiagramFrame/SeqDiagram/DragLine.tsx @@ -0,0 +1,55 @@ +import { useDocumentEvent } from "@/functions/useDocumentEvent"; +import { dragParticipantAtom } from "@/store/Store"; +import { useAtom } from "jotai"; +import { useRef, useState } from "react"; + +export const DragLine = () => { + const [position, setPosition] = useState([0, 0]); + const [dragParticipant, setDragParticipant] = useAtom(dragParticipantAtom); + const elRef = useRef(null); + + useDocumentEvent("mousemove", (e) => { + const diagramRect = elRef.current?.getBoundingClientRect(); + if (!diagramRect) return; + if (dragParticipant) { + setPosition([e.clientX - diagramRect.left, e.clientY - diagramRect.top]); + } + }); + useDocumentEvent("mouseup", () => { + if (dragParticipant) { + setDragParticipant(undefined); + setPosition([0, 0]); + } + }); + + if (!dragParticipant) return null; + return ( + + + + + + + + + ); +}; diff --git a/src/components/DiagramFrame/SeqDiagram/LifeLineLayer/Participant.tsx b/src/components/DiagramFrame/SeqDiagram/LifeLineLayer/Participant.tsx index 00109817..1acac3fc 100644 --- a/src/components/DiagramFrame/SeqDiagram/LifeLineLayer/Participant.tsx +++ b/src/components/DiagramFrame/SeqDiagram/LifeLineLayer/Participant.tsx @@ -3,8 +3,11 @@ import useIntersectionTop from "@/functions/useIntersectionTop"; import { _STARTER_ } from "@/parser/OrderedParticipants"; import { PARTICIPANT_HEIGHT } from "@/positioning/Constants"; import { + codeAtom, diagramElementAtom, + dragParticipantAtom, modeAtom, + onContentChangeAtom, onSelectAtom, participantsAtom, RenderMode, @@ -14,18 +17,21 @@ import { import { cn } from "@/utils"; import { brightnessIgnoreAlpha, removeAlpha } from "@/utils/Color"; import { getElementDistanceToTop } from "@/utils/dom"; -import { useAtomValue, useSetAtom } from "jotai"; +import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { useMemo, useRef } from "react"; import { ParticipantLabel } from "./ParticipantLabel"; import iconPath from "../../Tutorial/Icons"; const INTERSECTION_ERROR_MARGIN = 10; +const formatName = (name: string) => (name.includes(" ") ? `"${name}"` : name); + export const Participant = (props: { entity: Record; offsetTop2?: number; }) => { const elRef = useRef(null); + const [code, setCode] = useAtom(codeAtom); const mode = useAtomValue(modeAtom); const participants = useAtomValue(participantsAtom); const diagramElement = useAtomValue(diagramElementAtom); @@ -34,6 +40,8 @@ export const Participant = (props: { const onSelect = useSetAtom(onSelectAtom); const intersectionTop = useIntersectionTop(); const [scrollTop] = useDocumentScroll(); + const [dragParticipant, setDragParticipant] = useAtom(dragParticipantAtom); + const onContentChange = useAtomValue(onContentChangeAtom); const isDefaultStarter = props.entity.name === _STARTER_; @@ -89,51 +97,89 @@ export const Participant = (props: { ? iconPath["actor"] : iconPath[props.entity.type?.toLowerCase() as "actor"]; + const handleDrag = () => { + const { + left = 0, + top = 0, + width = 0, + height = 0, + } = elRef.current?.getBoundingClientRect() || {}; + const diagramRect = diagramElement?.getBoundingClientRect(); + setDragParticipant({ + name: props.entity.name, + x: left + width / 2 - (diagramRect?.left || 0), + y: top + height / 2 - (diagramRect?.top || 0), + }); + }; + const handleDrop = () => { + if (dragParticipant && dragParticipant.name !== props.entity.name) { + const isFromStarter = dragParticipant.name === _STARTER_; + const newCode = + code + + (isFromStarter + ? `\n${formatName(props.entity.name)}.message` + : `\n${formatName(dragParticipant.name)} -> ${formatName(props.entity.name)}.message`); + setCode(newCode); + onContentChange(newCode); + } + }; + return (
onSelect(props.entity.name)} - data-participant-id={props.entity.name} > -
- {icon && ( -
+
- {props.entity.stereotype && ( - - )} - onSelect(props.entity.name)} + data-participant-id={props.entity.name} + onMouseDown={handleDrag} + onMouseUp={handleDrop} + > +
+ {icon && ( +
-
- )} + )} + + {!isDefaultStarter && ( +
+ {props.entity.stereotype && ( + + )} + +
+ )} +
); diff --git a/src/components/DiagramFrame/SeqDiagram/MessageLayer/MessageLabel.tsx b/src/components/DiagramFrame/SeqDiagram/MessageLayer/MessageLabel.tsx index 40dcde61..10ddb41f 100644 --- a/src/components/DiagramFrame/SeqDiagram/MessageLayer/MessageLabel.tsx +++ b/src/components/DiagramFrame/SeqDiagram/MessageLayer/MessageLabel.tsx @@ -17,7 +17,7 @@ export const MessageLabel = (props: { const mode = useAtomValue(modeAtom); const [code, setCode] = useAtom(codeAtom); const onContentChange = useAtomValue(onContentChangeAtom); - const formattedLabelText = formatText(props.labelText); + const formattedLabelText = formatText(props.labelText || ""); const replaceLabelText = (e: FocusEvent | KeyboardEvent | MouseEvent) => { e.preventDefault(); diff --git a/src/components/DiagramFrame/SeqDiagram/SeqDiagram.tsx b/src/components/DiagramFrame/SeqDiagram/SeqDiagram.tsx index ff65d858..78fe3abf 100644 --- a/src/components/DiagramFrame/SeqDiagram/SeqDiagram.tsx +++ b/src/components/DiagramFrame/SeqDiagram/SeqDiagram.tsx @@ -21,6 +21,7 @@ import "./SeqDiagram.css"; import { cn } from "@/utils"; import { LifeLineLayer } from "./LifeLineLayer/LifeLineLayer"; import { MessageLayer } from "./MessageLayer/MessageLayer"; +import { DragLine } from "./DragLine"; export const SeqDiagram = (props: { className?: string; @@ -60,7 +61,7 @@ export const SeqDiagram = (props: { return (
)}
+
); }; diff --git a/src/store/Store.ts b/src/store/Store.ts index 95ea385b..ac28f27e 100644 --- a/src/store/Store.ts +++ b/src/store/Store.ts @@ -104,4 +104,10 @@ export const onEventEmitAtom = atomWithFunctionValue< (name: string, data: any) => void >(() => {}); +export const dragParticipantAtom = atom<{ + name: string; + x: number; + y: number; +}>(); + export default store; From 948ff8b6548512652fb3a552b13c2a5d8095fc04 Mon Sep 17 00:00:00 2001 From: dyon21 Date: Mon, 14 Jul 2025 02:21:09 +0800 Subject: [PATCH 2/5] feat: allow dragging lifeline anchors to create messages --- .../DiagramFrame/SeqDiagram/DragLine.tsx | 55 +++++++++++----- .../SeqDiagram/LifeLineLayer/LifeLine.tsx | 64 ++++++++++++++++++- .../SeqDiagram/LifeLineLayer/Participant.tsx | 7 +- .../SeqDiagram/MessageLayer/Anchor.tsx | 49 ++++++++++++++ .../SeqDiagram/MessageLayer/Block/Block.tsx | 45 ++++++++----- .../Interaction/Occurrence/Occurrence.tsx | 9 ++- .../SelfInvocation/SelfInvocation.tsx | 2 +- .../Block/Statement/Message/index.tsx | 9 ++- .../SeqDiagram/MessageLayer/MessageLayer.tsx | 2 +- .../SeqDiagram/MessageLayer/StylePanel.tsx | 21 ++++-- .../DiagramFrame/SeqDiagram/SeqDiagram.tsx | 2 +- src/store/Store.ts | 8 +++ src/utils/StringUtil.ts | 52 ++++++++++++++- 13 files changed, 274 insertions(+), 51 deletions(-) create mode 100644 src/components/DiagramFrame/SeqDiagram/MessageLayer/Anchor.tsx diff --git a/src/components/DiagramFrame/SeqDiagram/DragLine.tsx b/src/components/DiagramFrame/SeqDiagram/DragLine.tsx index 4a373809..6f4d3e2c 100644 --- a/src/components/DiagramFrame/SeqDiagram/DragLine.tsx +++ b/src/components/DiagramFrame/SeqDiagram/DragLine.tsx @@ -1,18 +1,23 @@ import { useDocumentEvent } from "@/functions/useDocumentEvent"; -import { dragParticipantAtom } from "@/store/Store"; -import { useAtom } from "jotai"; +import { dragAnchorAtom, dragParticipantAtom, scaleAtom } from "@/store/Store"; +import { useAtom, useAtomValue } from "jotai"; import { useRef, useState } from "react"; export const DragLine = () => { + const scale = useAtomValue(scaleAtom); const [position, setPosition] = useState([0, 0]); const [dragParticipant, setDragParticipant] = useAtom(dragParticipantAtom); + const [dragAnchor, setDragAnchor] = useAtom(dragAnchorAtom); const elRef = useRef(null); useDocumentEvent("mousemove", (e) => { const diagramRect = elRef.current?.getBoundingClientRect(); if (!diagramRect) return; - if (dragParticipant) { - setPosition([e.clientX - diagramRect.left, e.clientY - diagramRect.top]); + if (dragParticipant || dragAnchor) { + setPosition([ + (e.clientX - diagramRect.left) / scale, + (e.clientY - diagramRect.top) / scale, + ]); } }); useDocumentEvent("mouseup", () => { @@ -20,13 +25,17 @@ export const DragLine = () => { setDragParticipant(undefined); setPosition([0, 0]); } + if (dragAnchor) { + setDragAnchor(undefined); + setPosition([0, 0]); + } }); - if (!dragParticipant) return null; + if (!dragParticipant && !dragAnchor) return null; return ( { - + {dragParticipant && ( + + )} + {dragAnchor && ( + + )} ); }; diff --git a/src/components/DiagramFrame/SeqDiagram/LifeLineLayer/LifeLine.tsx b/src/components/DiagramFrame/SeqDiagram/LifeLineLayer/LifeLine.tsx index 0010f251..5a95b505 100644 --- a/src/components/DiagramFrame/SeqDiagram/LifeLineLayer/LifeLine.tsx +++ b/src/components/DiagramFrame/SeqDiagram/LifeLineLayer/LifeLine.tsx @@ -1,14 +1,29 @@ -import { diagramElementAtom, scaleAtom } from "@/store/Store"; -import { useAtomValue } from "jotai"; +import { + codeAtom, + diagramElementAtom, + dragAnchorAtom, + onContentChangeAtom, + scaleAtom, +} from "@/store/Store"; +import { useAtom, useAtomValue } from "jotai"; import { useCallback, useEffect, useRef, useState } from "react"; import parentLogger from "@/logger/logger"; import { EventBus } from "@/EventBus"; import { cn } from "@/utils"; import { Participant } from "./Participant"; import { centerOf } from "../MessageLayer/Block/Statement/utils"; +import { + getCurrentLine, + getLeadingSpaces, + getLineTail, + getPrevLine, + getPrevNotCommentLineTail, +} from "@/utils/StringUtil"; const logger = parentLogger.child({ name: "LifeLine" }); +const INDENT = " "; + export const LifeLine = (props: { entity: any; groupLeft?: any; @@ -17,6 +32,8 @@ export const LifeLine = (props: { className?: string; }) => { const elRef = useRef(null); + const [code, setCode] = useAtom(codeAtom); + const onContentChange = useAtomValue(onContentChangeAtom); const scale = useAtomValue(scaleAtom); const diagramElement = useAtomValue(diagramElementAtom); const PARTICIPANT_TOP_SPACE_FOR_GROUP = 20; @@ -61,6 +78,38 @@ export const LifeLine = (props: { EventBus.on("participant_set_top", () => setTimeout(() => updateTop(), 0)); }, [props.entity, updateTop]); + const dragAnchor = useAtomValue(dragAnchorAtom); + const handleDrop = () => { + if (!dragAnchor) return; + let newCode = code; + const messageContent = `${props.entity.name}.method`; + if (typeof dragAnchor.index !== "number") { + const start = dragAnchor.context.children[0]?.start?.start; + const insertPosition = getLineTail(code, start); + const prev = code.slice(0, insertPosition); + const next = code.slice(insertPosition); + const leadingSpaces = getLeadingSpaces( + getPrevLine(code, insertPosition + 1), + ); + newCode = `${prev} {\n${leadingSpaces}${INDENT}${messageContent}\n${leadingSpaces}}${next}`; + } else if (dragAnchor.index < dragAnchor.context.children.length) { + const start = dragAnchor.context.children[dragAnchor.index]?.start?.start; + const insertPosition = getPrevNotCommentLineTail(code, start) + 1; + const prev = code.slice(0, insertPosition); + const next = code.slice(insertPosition); + newCode = `${prev}${getLeadingSpaces(next)}${messageContent}\n${next}`; + } else { + const start = + dragAnchor.context.children.at(-1)?.stop?.stop || code.length; + const insertPosition = getLineTail(code, start); + const prev = code.slice(0, insertPosition); + const next = code.slice(insertPosition); + newCode = `${prev}\n${getLeadingSpaces(getCurrentLine(code, insertPosition))}${messageContent}\n${next}`; + } + setCode(newCode); + onContentChange(newCode); + }; + return (
)} {props.renderLifeLine && ( -
+ <> +
+
+ )}
); diff --git a/src/components/DiagramFrame/SeqDiagram/LifeLineLayer/Participant.tsx b/src/components/DiagramFrame/SeqDiagram/LifeLineLayer/Participant.tsx index 1acac3fc..2edf570e 100644 --- a/src/components/DiagramFrame/SeqDiagram/LifeLineLayer/Participant.tsx +++ b/src/components/DiagramFrame/SeqDiagram/LifeLineLayer/Participant.tsx @@ -11,6 +11,7 @@ import { onSelectAtom, participantsAtom, RenderMode, + scaleAtom, selectedAtom, stickyOffsetAtom, } from "@/store/Store"; @@ -96,7 +97,7 @@ export const Participant = (props: { const icon = isDefaultStarter ? iconPath["actor"] : iconPath[props.entity.type?.toLowerCase() as "actor"]; - + const scale = useAtomValue(scaleAtom); const handleDrag = () => { const { left = 0, @@ -107,8 +108,8 @@ export const Participant = (props: { const diagramRect = diagramElement?.getBoundingClientRect(); setDragParticipant({ name: props.entity.name, - x: left + width / 2 - (diagramRect?.left || 0), - y: top + height / 2 - (diagramRect?.top || 0), + x: (left + width / 2) / scale - (diagramRect?.left || 0), + y: (top + height / 2) / scale - (diagramRect?.top || 0), }); }; const handleDrop = () => { diff --git a/src/components/DiagramFrame/SeqDiagram/MessageLayer/Anchor.tsx b/src/components/DiagramFrame/SeqDiagram/MessageLayer/Anchor.tsx new file mode 100644 index 00000000..822c983a --- /dev/null +++ b/src/components/DiagramFrame/SeqDiagram/MessageLayer/Anchor.tsx @@ -0,0 +1,49 @@ +import { diagramElementAtom, dragAnchorAtom, scaleAtom } from "@/store/Store"; +import { useAtomValue, useSetAtom } from "jotai"; +import { useRef } from "react"; + +export const Anchor = (props: { + context: any; + participant: string; + index?: number; +}) => { + const elRef = useRef(null); + const scale = useAtomValue(scaleAtom) || 0; + const diagramElement = useAtomValue(diagramElementAtom); + const setAnchor = useSetAtom(dragAnchorAtom); + return ( +
{ + e.stopPropagation(); + const { + left = 0, + top = 0, + width = 0, + height = 0, + } = elRef.current?.getBoundingClientRect() || {}; + const diagramRect = diagramElement?.getBoundingClientRect(); + setAnchor({ + context: props.context, + index: props.index, + participant: props.participant, + x: (left + width / 2 - (diagramRect?.left || 0)) / scale, + y: (top + height / 2 - (diagramRect?.top || 0)) / scale, + }); + }} + > + + + + +
+ ); +}; diff --git a/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Block.tsx b/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Block.tsx index fada1530..ddef80b7 100644 --- a/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Block.tsx +++ b/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Block.tsx @@ -1,6 +1,8 @@ import { increaseNumber } from "@/utils/Numbering"; import { Statement } from "./Statement/Statement"; import { cn } from "@/utils"; +import { Fragment } from "react/jsx-runtime"; +import { Anchor } from "../Anchor"; export const Block = (props: { origin?: string; @@ -22,27 +24,38 @@ export const Block = (props: { }; return (
+ {statements.map((stat, index) => ( -
.return]:-mb-4 [&>.return]:bottom-[-1px]", - )} - data-origin={props.origin} - key={index} - > - +
.return]:-mb-4 [&>.return]:bottom-[-1px]", + )} + data-origin={props.origin} + > + +
+ -
+ ))}
); diff --git a/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Interaction/Occurrence/Occurrence.tsx b/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Interaction/Occurrence/Occurrence.tsx index 78f47b94..9a8f4113 100644 --- a/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Interaction/Occurrence/Occurrence.tsx +++ b/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Interaction/Occurrence/Occurrence.tsx @@ -4,6 +4,9 @@ import { useEffect, useState } from "react"; import { cn } from "@/utils"; import { Block } from "../../../Block"; import { centerOf } from "../../utils"; +import { Anchor } from "../../../../Anchor"; +import { useAtomValue } from "jotai"; +import { dragAnchorAtom } from "@/store/Store"; export const Occurrence = (props: { context: any; @@ -13,6 +16,7 @@ export const Occurrence = (props: { className?: string; }) => { const [collapsed, setCollapsed] = useState(false); + const dragAnchor = useAtomValue(dragAnchorAtom); const debug = localStorage.getItem("zenumlDebug"); @@ -50,6 +54,7 @@ export const Occurrence = (props: { className={cn( "occurrence min-h-6 shadow-occurrence border-skin-occurrence bg-skin-occurrence rounded-sm border-2 relative left-full w-[15px] mt-[-2px] pl-[6px]", { "right-to-left left-[-14px]": props.rtl }, + dragAnchor ? "pointer-events-none" : "pointer-events-auto", props.className, )} data-el-type="occurrence" @@ -70,13 +75,15 @@ export const Occurrence = (props: { {hasAnyStatementsExceptReturn() && ( )} - {props.context.braceBlock() && ( + {props.context.braceBlock() ? ( + ) : ( + )}
); diff --git a/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Interaction/SelfInvocation/SelfInvocation.tsx b/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Interaction/SelfInvocation/SelfInvocation.tsx index 18eb4898..b07880c1 100644 --- a/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Interaction/SelfInvocation/SelfInvocation.tsx +++ b/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Interaction/SelfInvocation/SelfInvocation.tsx @@ -27,7 +27,7 @@ export const SelfInvocation = (props: { return (