diff --git a/src/components/DiagramFrame/SeqDiagram/DragLine.tsx b/src/components/DiagramFrame/SeqDiagram/DragLine.tsx new file mode 100644 index 00000000..6f4d3e2c --- /dev/null +++ b/src/components/DiagramFrame/SeqDiagram/DragLine.tsx @@ -0,0 +1,78 @@ +import { useDocumentEvent } from "@/functions/useDocumentEvent"; +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 || dragAnchor) { + setPosition([ + (e.clientX - diagramRect.left) / scale, + (e.clientY - diagramRect.top) / scale, + ]); + } + }); + useDocumentEvent("mouseup", () => { + if (dragParticipant) { + setDragParticipant(undefined); + setPosition([0, 0]); + } + if (dragAnchor) { + setDragAnchor(undefined); + setPosition([0, 0]); + } + }); + + 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 fe0e12a2..f8238dac 100644 --- a/src/components/DiagramFrame/SeqDiagram/LifeLineLayer/LifeLine.tsx +++ b/src/components/DiagramFrame/SeqDiagram/LifeLineLayer/LifeLine.tsx @@ -1,6 +1,7 @@ import { coordinatesAtom, diagramElementAtom, + dragAnchorAtom, lifelineReadyAtom, scaleAtom, } from "@/store/Store"; @@ -11,6 +12,7 @@ import { EventBus } from "@/EventBus"; import { cn } from "@/utils"; import { Participant } from "./Participant"; import { centerOf } from "../MessageLayer/Block/Statement/utils"; +import { useAnchorDrop } from "./useAnchorDrop"; import { _STARTER_ } from "@/parser/OrderedParticipants"; const logger = parentLogger.child({ name: "LifeLine" }); @@ -79,6 +81,9 @@ export const LifeLine = (props: { EventBus.on("participant_set_top", () => setTimeout(() => updateTop(), 0)); }, [props.entity, updateTop]); + const dragAnchor = useAtomValue(dragAnchorAtom); + const { handleDrop } = useAnchorDrop(props.entity.name); + return (
)} {props.renderLifeLine && ( -
+ <> +
+
+ )}
); diff --git a/src/components/DiagramFrame/SeqDiagram/LifeLineLayer/Participant.tsx b/src/components/DiagramFrame/SeqDiagram/LifeLineLayer/Participant.tsx index 00109817..2edf570e 100644 --- a/src/components/DiagramFrame/SeqDiagram/LifeLineLayer/Participant.tsx +++ b/src/components/DiagramFrame/SeqDiagram/LifeLineLayer/Participant.tsx @@ -3,29 +3,36 @@ 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, + scaleAtom, selectedAtom, stickyOffsetAtom, } from "@/store/Store"; 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 +41,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_; @@ -88,52 +97,90 @@ 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, + top = 0, + width = 0, + height = 0, + } = elRef.current?.getBoundingClientRect() || {}; + const diagramRect = diagramElement?.getBoundingClientRect(); + setDragParticipant({ + name: props.entity.name, + x: (left + width / 2) / scale - (diagramRect?.left || 0), + y: (top + height / 2) / scale - (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/LifeLineLayer/useAnchorDrop.ts b/src/components/DiagramFrame/SeqDiagram/LifeLineLayer/useAnchorDrop.ts new file mode 100644 index 00000000..3df9ba25 --- /dev/null +++ b/src/components/DiagramFrame/SeqDiagram/LifeLineLayer/useAnchorDrop.ts @@ -0,0 +1,67 @@ +import { codeAtom, dragAnchorAtom, onContentChangeAtom } from "@/store/Store"; +import { + getCurrentLine, + getLeadingSpaces, + getLineTail, + getPrevLine, + getPrevNotCommentLineTail, +} from "@/utils/StringUtil"; +import { useAtom, useAtomValue } from "jotai"; + +const DEFAULT_INDENT = " "; + +export const useAnchorDrop = (entity: string) => { + const [code, setCode] = useAtom(codeAtom); + const dragAnchor = useAtomValue(dragAnchorAtom); + const onContentChange = useAtomValue(onContentChangeAtom); + const handleDrop = () => { + if (!dragAnchor) return; + let newCode = code; + const messageContent = `${entity}."${dragAnchor.id}"`; + if (typeof dragAnchor.index !== "number") { + const braces = dragAnchor.context?.braceBlock?.(); + if (braces) { + console.log(braces); + // insert new message inside empty braces + const prev = code.slice(0, braces.start.start); + const next = code.slice(braces.stop.stop + 1); + const leadingSpaces = getLeadingSpaces( + getCurrentLine(code, braces.start.start), + ); + newCode = `${prev}{\n${leadingSpaces}${DEFAULT_INDENT}${messageContent}\n${leadingSpaces}}${next}`; + } else { + // insert new message with new braces + 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}${DEFAULT_INDENT}${messageContent}\n${leadingSpaces}}${next}`; + } + } else if (dragAnchor.index < dragAnchor.context.children.length) { + // insert new message inside a block + 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 { + // insert new message at the end of a block + 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); + const leadingSpaces = getLeadingSpaces( + getCurrentLine(code, insertPosition), + ); + newCode = `${prev}\n${leadingSpaces}${messageContent}\n${next}`; + } + setCode(newCode); + onContentChange(newCode); + }; + + return { 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..7b4ac266 --- /dev/null +++ b/src/components/DiagramFrame/SeqDiagram/MessageLayer/Anchor.tsx @@ -0,0 +1,181 @@ +import { + diagramElementAtom, + dragAnchorAtom, + lastCreatedStatementAtom, + scaleAtom, +} from "@/store/Store"; +import { cn } from "@/utils"; +import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import { useRef } from "react"; + +const icon = ( + + + + +); + +const getRandomContent = () => { + const wordList = [ + "valley", + "tunnel", + "lesson", + "prosper", + "burden", + "alert", + "lion", + "shell", + "rhythm", + "turkey", + "crouch", + "angry", + "garlic", + "decade", + "mistake", + "ladder", + "donor", + "crop", + "debate", + "army", + "boil", + "figure", + "half", + "fall", + "business", + "pair", + "remind", + "clinic", + "stereo", + "hamster", + "rookie", + "sand", + "huge", + "gym", + "lobster", + "eyebrow", + "castle", + "youth", + "middle", + "custom", + "flame", + "exhibit", + "pave", + "kitten", + "mirror", + "pulp", + "heavy", + "adjust", + "shrimp", + "script", + "energy", + "hotel", + "glue", + "engage", + "unlock", + "fire", + "child", + "grass", + "avoid", + "gasp", + "vessel", + "ethics", + "brass", + "guilt", + "palace", + "limit", + "upset", + "lava", + "embody", + "narrow", + "badge", + "slogan", + "river", + "tooth", + "license", + "concert", + "lemon", + "quantum", + "success", + "creek", + "ginger", + "stock", + "exhibit", + "dolphin", + "sweet", + "nuclear", + "pistol", + "expire", + "coin", + "scare", + "jump", + "pencil", + "unique", + "game", + "spice", + "genuine", + "swallow", + "online", + "equip", + "twist", + ]; + return `${wordList[Math.floor(Math.random() * wordList.length)]} ${wordList[Math.floor(Math.random() * wordList.length)]}`; +}; + +export const Anchor = (props: { + context: any; + participant: string; + index?: number; + offset?: number; +}) => { + const elRef = useRef(null); + const scale = useAtomValue(scaleAtom) || 0; + const diagramElement = useAtomValue(diagramElementAtom); + const [anchor, setAnchor] = useAtom(dragAnchorAtom); + const setLastCreatedStatement = useSetAtom(lastCreatedStatementAtom); + const id = useRef(getRandomContent()); + return ( +
+
{ + e.stopPropagation(); + const { + left = 0, + top = 0, + height = 0, + } = elRef.current?.getBoundingClientRect() || {}; + const diagramRect = diagramElement?.getBoundingClientRect(); + setAnchor({ + id: id.current, + context: props.context, + index: props.index, + participant: props.participant, + x: (left - (diagramRect?.left || 0)) / scale, + y: + (top + + height / 2 - + (diagramRect?.top || 0) + + (props.offset ?? 25)) / + scale, + }); + setLastCreatedStatement(id.current); + }} + > + {icon} +
+
+ ); +}; diff --git a/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Block.tsx b/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Block.tsx index fada1530..b1b75876 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/Interaction.tsx b/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Interaction/Interaction.tsx index 064bfdf7..27e6cd61 100644 --- a/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Interaction/Interaction.tsx +++ b/src/components/DiagramFrame/SeqDiagram/MessageLayer/Block/Statement/Interaction/Interaction.tsx @@ -4,7 +4,7 @@ import { SelfInvocation } from "./SelfInvocation/SelfInvocation"; import { Message } from "../Message"; import { Occurrence } from "./Occurrence/Occurrence"; import { useAtomValue } from "jotai"; -import { cursorAtom } from "@/store/Store"; +import { cursorAtom, lastCreatedStatementAtom } from "@/store/Store"; import { _STARTER_ } from "@/parser/OrderedParticipants"; import { Comment } from "../Comment/Comment"; import { useArrow } from "../useArrow"; @@ -16,6 +16,7 @@ export const Interaction = (props: { number?: string; className?: string; }) => { + const lastCreatedStatement = useAtomValue(lastCreatedStatementAtom); const cursor = useAtomValue(cursorAtom); const messageTextStyle = props.commentObj?.messageStyle; const messageClassNames = props.commentObj?.messageClassNames; @@ -68,45 +69,48 @@ export const Interaction = (props: { transform: "translateX(" + translateX + "px)", }} > - {props.commentObj?.text && } - {isSelf ? ( - + {props.commentObj?.text && } + {isSelf ? ( + + ) : ( + + )} + - ) : ( - - )} - - {assignee && !isSelf && ( - - )} + {assignee && !isSelf && ( + + )} +
); }; 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 08fe347e..1385aaf4 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,8 @@ import { useEffect, useState } from "react"; import { cn } from "@/utils"; import { Block } from "../../../Block"; import { centerOf } from "../../utils"; +import { Anchor } from "../../../../Anchor"; +import { dragAnchorAtom } from "@/store/Store"; import { useAtomValue } from "jotai"; import { coordinatesAtom } from "@/store/Store"; @@ -16,6 +18,7 @@ export const Occurrence = (props: { }) => { const coordinates = useAtomValue(coordinatesAtom); const [collapsed, setCollapsed] = useState(false); + const dragAnchor = useAtomValue(dragAnchorAtom); const debug = localStorage.getItem("zenumlDebug"); @@ -53,6 +56,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" @@ -71,15 +75,23 @@ export const Occurrence = (props: { )} {hasAnyStatementsExceptReturn() && ( - +
+ +
)} - {props.context.braceBlock() && ( + {props.context.braceBlock()?.block() ? ( + ) : ( + )}
); 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 6ad9f4b4..2503d451 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 (