@adatechnology/conversations-ui 0.1.0-rc.40 → 0.1.0-rc.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/flows/index.js
CHANGED
|
@@ -858,8 +858,42 @@ function FlowPortalNode({ data }) {
|
|
|
858
858
|
var flowPortalNodeTypes = { flowPortal: FlowPortalNode };
|
|
859
859
|
|
|
860
860
|
// src/flows/FlowPalette.tsx
|
|
861
|
-
import { useEffect, useRef, useState as useState2 } from "react";
|
|
861
|
+
import { useEffect, useLayoutEffect, useRef, useState as useState2 } from "react";
|
|
862
862
|
import { Plus as Plus2, MessageCircleQuestion as MessageCircleQuestion2, GitBranch as GitBranch3, Zap as Zap2, Diamond as Diamond2, ChevronRight } from "lucide-react";
|
|
863
|
+
|
|
864
|
+
// src/flows/flowMenuPlacement.ts
|
|
865
|
+
var DEFAULT_GAP = 4;
|
|
866
|
+
var DEFAULT_MARGIN = 8;
|
|
867
|
+
function clamp(value, minimum, maximum) {
|
|
868
|
+
return Math.max(minimum, Math.min(value, Math.max(minimum, maximum)));
|
|
869
|
+
}
|
|
870
|
+
function placeFloatingPanel({
|
|
871
|
+
anchor,
|
|
872
|
+
panel,
|
|
873
|
+
viewport,
|
|
874
|
+
prefer,
|
|
875
|
+
gap = DEFAULT_GAP,
|
|
876
|
+
margin = DEFAULT_MARGIN
|
|
877
|
+
}) {
|
|
878
|
+
const maxHeight = Math.min(panel.height, viewport.height - margin * 2);
|
|
879
|
+
let left;
|
|
880
|
+
if (prefer === "side") {
|
|
881
|
+
const toTheRight = anchor.right + gap;
|
|
882
|
+
const toTheLeft = anchor.left - gap - panel.width;
|
|
883
|
+
left = toTheRight + panel.width <= viewport.width - margin ? toTheRight : toTheLeft;
|
|
884
|
+
} else {
|
|
885
|
+
left = anchor.left + gap;
|
|
886
|
+
}
|
|
887
|
+
left = clamp(left, margin, viewport.width - margin - panel.width);
|
|
888
|
+
const top = prefer === "side" ? anchor.top : anchor.bottom;
|
|
889
|
+
return {
|
|
890
|
+
left,
|
|
891
|
+
top: clamp(top, margin, viewport.height - margin - maxHeight),
|
|
892
|
+
maxHeight
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// src/flows/FlowPalette.tsx
|
|
863
897
|
import { Fragment, jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
|
|
864
898
|
var QUESTION_TYPES = ["text", "money", "date", "int", "cpf"];
|
|
865
899
|
function FlowPaletteMenu({ onSelect, labels, actionOptions }) {
|
|
@@ -867,6 +901,38 @@ function FlowPaletteMenu({ onSelect, labels, actionOptions }) {
|
|
|
867
901
|
{ actionKind: "handoff", label: labels.actionKindLabels.handoff ?? "Encaminhar para atendimento" }
|
|
868
902
|
];
|
|
869
903
|
const [submenu, setSubmenu] = useState2(null);
|
|
904
|
+
const questionTriggerRef = useRef(null);
|
|
905
|
+
const actionTriggerRef = useRef(null);
|
|
906
|
+
const submenuRef = useRef(null);
|
|
907
|
+
const [placement, setPlacement] = useState2(null);
|
|
908
|
+
useLayoutEffect(() => {
|
|
909
|
+
if (!submenu) {
|
|
910
|
+
setPlacement(null);
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
const trigger = submenu === "question" ? questionTriggerRef.current : actionTriggerRef.current;
|
|
914
|
+
const panel = submenuRef.current;
|
|
915
|
+
if (!trigger || !panel) return;
|
|
916
|
+
const anchor = trigger.getBoundingClientRect();
|
|
917
|
+
setPlacement(
|
|
918
|
+
placeFloatingPanel({
|
|
919
|
+
anchor: { left: anchor.left, top: anchor.top, right: anchor.right, bottom: anchor.bottom },
|
|
920
|
+
panel: { width: panel.offsetWidth, height: panel.scrollHeight },
|
|
921
|
+
viewport: { width: window.innerWidth, height: window.innerHeight },
|
|
922
|
+
prefer: "side"
|
|
923
|
+
})
|
|
924
|
+
);
|
|
925
|
+
}, [submenu]);
|
|
926
|
+
const submenuStyle = {
|
|
927
|
+
position: "fixed",
|
|
928
|
+
left: placement?.left ?? 0,
|
|
929
|
+
top: placement?.top ?? 0,
|
|
930
|
+
maxHeight: placement?.maxHeight,
|
|
931
|
+
overflowY: "auto",
|
|
932
|
+
// Até a medição terminar o painel existe mas não aparece — senão ele pisca um quadro na
|
|
933
|
+
// posição errada, que é justamente o salto que esta correção remove.
|
|
934
|
+
visibility: placement ? "visible" : "hidden"
|
|
935
|
+
};
|
|
870
936
|
function select(spec) {
|
|
871
937
|
onSelect(spec);
|
|
872
938
|
setSubmenu(null);
|
|
@@ -876,6 +942,7 @@ function FlowPaletteMenu({ onSelect, labels, actionOptions }) {
|
|
|
876
942
|
/* @__PURE__ */ jsxs6(
|
|
877
943
|
"button",
|
|
878
944
|
{
|
|
945
|
+
ref: questionTriggerRef,
|
|
879
946
|
"data-cv-tooltip": labels.palette.question,
|
|
880
947
|
"aria-label": labels.palette.question,
|
|
881
948
|
onMouseEnter: () => setSubmenu("question"),
|
|
@@ -891,17 +958,25 @@ function FlowPaletteMenu({ onSelect, labels, actionOptions }) {
|
|
|
891
958
|
]
|
|
892
959
|
}
|
|
893
960
|
),
|
|
894
|
-
submenu === "question" && /* @__PURE__ */ jsx7(
|
|
895
|
-
"
|
|
961
|
+
submenu === "question" && /* @__PURE__ */ jsx7(
|
|
962
|
+
"div",
|
|
896
963
|
{
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
964
|
+
ref: submenuRef,
|
|
965
|
+
style: submenuStyle,
|
|
966
|
+
className: "z-50 w-56 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1",
|
|
967
|
+
children: QUESTION_TYPES.map((qt) => /* @__PURE__ */ jsx7(
|
|
968
|
+
"button",
|
|
969
|
+
{
|
|
970
|
+
"data-cv-tooltip": labels.questionTypeLabels[qt],
|
|
971
|
+
"aria-label": labels.questionTypeLabels[qt],
|
|
972
|
+
onClick: () => select({ kind: "question", questionType: qt }),
|
|
973
|
+
className: "w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700",
|
|
974
|
+
children: labels.questionTypeLabels[qt]
|
|
975
|
+
},
|
|
976
|
+
qt
|
|
977
|
+
))
|
|
978
|
+
}
|
|
979
|
+
)
|
|
905
980
|
] }),
|
|
906
981
|
/* @__PURE__ */ jsxs6(
|
|
907
982
|
"button",
|
|
@@ -937,6 +1012,7 @@ function FlowPaletteMenu({ onSelect, labels, actionOptions }) {
|
|
|
937
1012
|
/* @__PURE__ */ jsxs6(
|
|
938
1013
|
"button",
|
|
939
1014
|
{
|
|
1015
|
+
ref: actionTriggerRef,
|
|
940
1016
|
"data-cv-tooltip": labels.palette.action,
|
|
941
1017
|
"aria-label": labels.palette.action,
|
|
942
1018
|
onMouseEnter: () => setSubmenu("action"),
|
|
@@ -952,17 +1028,25 @@ function FlowPaletteMenu({ onSelect, labels, actionOptions }) {
|
|
|
952
1028
|
]
|
|
953
1029
|
}
|
|
954
1030
|
),
|
|
955
|
-
submenu === "action" && /* @__PURE__ */ jsx7(
|
|
956
|
-
"
|
|
1031
|
+
submenu === "action" && /* @__PURE__ */ jsx7(
|
|
1032
|
+
"div",
|
|
957
1033
|
{
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
1034
|
+
ref: submenuRef,
|
|
1035
|
+
style: submenuStyle,
|
|
1036
|
+
className: "z-50 w-64 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1",
|
|
1037
|
+
children: resolvedActionOptions.map((option) => /* @__PURE__ */ jsx7(
|
|
1038
|
+
"button",
|
|
1039
|
+
{
|
|
1040
|
+
"data-cv-tooltip": option.label,
|
|
1041
|
+
"aria-label": option.label,
|
|
1042
|
+
onClick: () => select({ kind: "action", actionKind: option.actionKind }),
|
|
1043
|
+
className: "w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700",
|
|
1044
|
+
children: option.label
|
|
1045
|
+
},
|
|
1046
|
+
option.actionKind
|
|
1047
|
+
))
|
|
1048
|
+
}
|
|
1049
|
+
)
|
|
966
1050
|
] })
|
|
967
1051
|
] });
|
|
968
1052
|
}
|
|
@@ -1541,7 +1625,7 @@ function FlowNodePanel({
|
|
|
1541
1625
|
}
|
|
1542
1626
|
|
|
1543
1627
|
// src/flows/FlowsWorkspace.tsx
|
|
1544
|
-
import { useCallback, useEffect as useEffect3, useMemo as useMemo2, useRef as useRef3, useState as useState6 } from "react";
|
|
1628
|
+
import { useCallback, useEffect as useEffect3, useLayoutEffect as useLayoutEffect2, useMemo as useMemo2, useRef as useRef3, useState as useState6 } from "react";
|
|
1545
1629
|
import {
|
|
1546
1630
|
ReactFlow as ReactFlow2,
|
|
1547
1631
|
Background as Background2,
|
|
@@ -1869,6 +1953,10 @@ var CHAIN_FRAME_PADDING = 36;
|
|
|
1869
1953
|
var QUICK_ADD_COLUMN_GAP = 320;
|
|
1870
1954
|
var FLOW_KEY_PATTERN = /^[a-z0-9_]{2,40}$/;
|
|
1871
1955
|
var EDGE_COLOR_LINEAR = "#94a3b8";
|
|
1956
|
+
var FOCUS_MAX_ZOOM = 1;
|
|
1957
|
+
var FOCUS_PADDING = 0.2;
|
|
1958
|
+
var FOCUS_DURATION_MS = 400;
|
|
1959
|
+
var QUICK_ADD_MENU_GAP = 12;
|
|
1872
1960
|
var EDGE_COLOR_BRANCH = "#8b5cf6";
|
|
1873
1961
|
var EDGE_COLOR_FALLBACK = "#cbd5e1";
|
|
1874
1962
|
var EDGE_COLOR_LIVE = "#3b82f6";
|
|
@@ -1937,6 +2025,9 @@ function FlowsWorkspace({
|
|
|
1937
2025
|
const [rfNodes, setRfNodes] = useState6([]);
|
|
1938
2026
|
const [flowInstance, setFlowInstance] = useState6(null);
|
|
1939
2027
|
const [pendingFocusNodeId, setPendingFocusNodeId] = useState6(null);
|
|
2028
|
+
const [pendingFocusFlowKey, setPendingFocusFlowKey] = useState6(null);
|
|
2029
|
+
const quickAddMenuRef = useRef3(null);
|
|
2030
|
+
const [quickAddPlacement, setQuickAddPlacement] = useState6(null);
|
|
1940
2031
|
const reloadGraphs = useCallback(async () => {
|
|
1941
2032
|
try {
|
|
1942
2033
|
const loaded = await api.getGraphs();
|
|
@@ -2030,6 +2121,7 @@ function FlowsWorkspace({
|
|
|
2030
2121
|
if (others.some(isFlowDirty) && !window.confirm(labels.workspace.unsavedChangesConfirm)) return;
|
|
2031
2122
|
setOpenFlowKeys(graphs ? mergedFlowKeysFrom(key, graphs) : [key]);
|
|
2032
2123
|
if (editingRef && editingRef.flowKey !== key) setEditingRef(null);
|
|
2124
|
+
setPendingFocusFlowKey(key);
|
|
2033
2125
|
},
|
|
2034
2126
|
[openFlowKeys, isFlowDirty, editingRef, graphs, labels]
|
|
2035
2127
|
);
|
|
@@ -2222,6 +2314,43 @@ function FlowsWorkspace({
|
|
|
2222
2314
|
flowInstance.setCenter(target.position.x + NODE_CARD_WIDTH / 2, target.position.y, { zoom: 1, duration: 400 });
|
|
2223
2315
|
setPendingFocusNodeId(null);
|
|
2224
2316
|
}, [pendingFocusNodeId, flowInstance, rfNodes]);
|
|
2317
|
+
useEffect3(() => {
|
|
2318
|
+
if (!pendingFocusFlowKey || !flowInstance) return;
|
|
2319
|
+
const flowNodes = rfNodes.filter((node) => parseNamespacedId(node.id).flowKey === pendingFocusFlowKey);
|
|
2320
|
+
if (flowNodes.length === 0) return;
|
|
2321
|
+
void flowInstance.fitView({
|
|
2322
|
+
nodes: flowNodes.map((node) => ({ id: node.id })),
|
|
2323
|
+
maxZoom: FOCUS_MAX_ZOOM,
|
|
2324
|
+
padding: FOCUS_PADDING,
|
|
2325
|
+
duration: FOCUS_DURATION_MS
|
|
2326
|
+
});
|
|
2327
|
+
setPendingFocusFlowKey(null);
|
|
2328
|
+
}, [pendingFocusFlowKey, flowInstance, rfNodes]);
|
|
2329
|
+
useLayoutEffect2(() => {
|
|
2330
|
+
if (!quickAddFrom) {
|
|
2331
|
+
setQuickAddPlacement(null);
|
|
2332
|
+
return;
|
|
2333
|
+
}
|
|
2334
|
+
const panel = quickAddMenuRef.current;
|
|
2335
|
+
if (!panel) return;
|
|
2336
|
+
const { x, y } = quickAddFrom.anchor;
|
|
2337
|
+
setQuickAddPlacement(
|
|
2338
|
+
placeFloatingPanel({
|
|
2339
|
+
anchor: { left: x, top: y, right: x, bottom: y },
|
|
2340
|
+
panel: { width: panel.offsetWidth, height: panel.scrollHeight },
|
|
2341
|
+
viewport: { width: window.innerWidth, height: window.innerHeight },
|
|
2342
|
+
prefer: "below",
|
|
2343
|
+
gap: QUICK_ADD_MENU_GAP
|
|
2344
|
+
})
|
|
2345
|
+
);
|
|
2346
|
+
}, [quickAddFrom]);
|
|
2347
|
+
const quickAddMenuStyle = {
|
|
2348
|
+
left: quickAddPlacement?.left ?? 0,
|
|
2349
|
+
top: quickAddPlacement?.top ?? 0,
|
|
2350
|
+
maxHeight: quickAddPlacement?.maxHeight,
|
|
2351
|
+
overflowY: "auto",
|
|
2352
|
+
visibility: quickAddPlacement ? "visible" : "hidden"
|
|
2353
|
+
};
|
|
2225
2354
|
const onNodesChange = useCallback((changes) => {
|
|
2226
2355
|
setRfNodes((current) => applyNodeChanges(changes, current));
|
|
2227
2356
|
}, []);
|
|
@@ -2603,8 +2732,9 @@ function FlowsWorkspace({
|
|
|
2603
2732
|
/* @__PURE__ */ jsxs11(
|
|
2604
2733
|
"div",
|
|
2605
2734
|
{
|
|
2735
|
+
ref: quickAddMenuRef,
|
|
2606
2736
|
className: "fixed z-50 w-64 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1",
|
|
2607
|
-
style:
|
|
2737
|
+
style: quickAddMenuStyle,
|
|
2608
2738
|
children: [
|
|
2609
2739
|
/* @__PURE__ */ jsx12("p", { className: "px-3 py-1.5 text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500", children: labels.quickAdd.title }),
|
|
2610
2740
|
/* @__PURE__ */ jsx12(
|
package/package.json
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from 'react'
|
|
1
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
2
2
|
import { Plus, MessageCircleQuestion, GitBranch, Zap, Diamond, ChevronRight } from 'lucide-react'
|
|
3
|
+
|
|
4
|
+
import { placeFloatingPanel, type FloatingPlacement } from './flowMenuPlacement'
|
|
3
5
|
import { DEFAULT_FLOW_EDITOR_LABELS, type FlowEditorLabels } from './labels'
|
|
4
6
|
import type { FlowActionKind, FlowQuestionType } from './flowGraph'
|
|
5
7
|
|
|
@@ -45,6 +47,43 @@ export function FlowPaletteMenu({ onSelect, labels, actionOptions }: FlowPalette
|
|
|
45
47
|
{ actionKind: 'handoff', label: labels.actionKindLabels.handoff ?? 'Encaminhar para atendimento' },
|
|
46
48
|
]
|
|
47
49
|
const [submenu, setSubmenu] = useState<'question' | 'action' | null>(null)
|
|
50
|
+
const questionTriggerRef = useRef<HTMLButtonElement>(null)
|
|
51
|
+
const actionTriggerRef = useRef<HTMLButtonElement>(null)
|
|
52
|
+
const submenuRef = useRef<HTMLDivElement>(null)
|
|
53
|
+
const [placement, setPlacement] = useState<FloatingPlacement | null>(null)
|
|
54
|
+
|
|
55
|
+
// `fixed` posicionado por medição, e não preso ao item por CSS: alinhado sempre à direita e ao
|
|
56
|
+
// topo do item, o submenu era cortado pela borda da tela — e sem rolagem os últimos itens ficavam
|
|
57
|
+
// inalcançáveis. Roda antes da pintura, então não pisca.
|
|
58
|
+
useLayoutEffect(() => {
|
|
59
|
+
if (!submenu) {
|
|
60
|
+
setPlacement(null)
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
const trigger = submenu === 'question' ? questionTriggerRef.current : actionTriggerRef.current
|
|
64
|
+
const panel = submenuRef.current
|
|
65
|
+
if (!trigger || !panel) return
|
|
66
|
+
const anchor = trigger.getBoundingClientRect()
|
|
67
|
+
setPlacement(
|
|
68
|
+
placeFloatingPanel({
|
|
69
|
+
anchor: { left: anchor.left, top: anchor.top, right: anchor.right, bottom: anchor.bottom },
|
|
70
|
+
panel: { width: panel.offsetWidth, height: panel.scrollHeight },
|
|
71
|
+
viewport: { width: window.innerWidth, height: window.innerHeight },
|
|
72
|
+
prefer: 'side',
|
|
73
|
+
}),
|
|
74
|
+
)
|
|
75
|
+
}, [submenu])
|
|
76
|
+
|
|
77
|
+
const submenuStyle = {
|
|
78
|
+
position: 'fixed' as const,
|
|
79
|
+
left: placement?.left ?? 0,
|
|
80
|
+
top: placement?.top ?? 0,
|
|
81
|
+
maxHeight: placement?.maxHeight,
|
|
82
|
+
overflowY: 'auto' as const,
|
|
83
|
+
// Até a medição terminar o painel existe mas não aparece — senão ele pisca um quadro na
|
|
84
|
+
// posição errada, que é justamente o salto que esta correção remove.
|
|
85
|
+
visibility: placement ? ('visible' as const) : ('hidden' as const),
|
|
86
|
+
}
|
|
48
87
|
|
|
49
88
|
function select(spec: NewNodeSpec) {
|
|
50
89
|
onSelect(spec)
|
|
@@ -55,6 +94,7 @@ export function FlowPaletteMenu({ onSelect, labels, actionOptions }: FlowPalette
|
|
|
55
94
|
<>
|
|
56
95
|
<div className="relative">
|
|
57
96
|
<button
|
|
97
|
+
ref={questionTriggerRef}
|
|
58
98
|
data-cv-tooltip={labels.palette.question} aria-label={labels.palette.question}
|
|
59
99
|
onMouseEnter={() => setSubmenu('question')}
|
|
60
100
|
onClick={() => setSubmenu(submenu === 'question' ? null : 'question')}
|
|
@@ -66,7 +106,11 @@ export function FlowPaletteMenu({ onSelect, labels, actionOptions }: FlowPalette
|
|
|
66
106
|
<ChevronRight size={13} className="text-gray-400" />
|
|
67
107
|
</button>
|
|
68
108
|
{submenu === 'question' && (
|
|
69
|
-
<div
|
|
109
|
+
<div
|
|
110
|
+
ref={submenuRef}
|
|
111
|
+
style={submenuStyle}
|
|
112
|
+
className="z-50 w-56 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1"
|
|
113
|
+
>
|
|
70
114
|
{QUESTION_TYPES.map((qt) => (
|
|
71
115
|
<button
|
|
72
116
|
data-cv-tooltip={labels.questionTypeLabels[qt]} aria-label={labels.questionTypeLabels[qt]}
|
|
@@ -101,6 +145,7 @@ export function FlowPaletteMenu({ onSelect, labels, actionOptions }: FlowPalette
|
|
|
101
145
|
|
|
102
146
|
<div className="relative">
|
|
103
147
|
<button
|
|
148
|
+
ref={actionTriggerRef}
|
|
104
149
|
data-cv-tooltip={labels.palette.action} aria-label={labels.palette.action}
|
|
105
150
|
onMouseEnter={() => setSubmenu('action')}
|
|
106
151
|
onClick={() => setSubmenu(submenu === 'action' ? null : 'action')}
|
|
@@ -112,7 +157,11 @@ export function FlowPaletteMenu({ onSelect, labels, actionOptions }: FlowPalette
|
|
|
112
157
|
<ChevronRight size={13} className="text-gray-400" />
|
|
113
158
|
</button>
|
|
114
159
|
{submenu === 'action' && (
|
|
115
|
-
<div
|
|
160
|
+
<div
|
|
161
|
+
ref={submenuRef}
|
|
162
|
+
style={submenuStyle}
|
|
163
|
+
className="z-50 w-64 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1"
|
|
164
|
+
>
|
|
116
165
|
{resolvedActionOptions.map((option) => (
|
|
117
166
|
<button
|
|
118
167
|
data-cv-tooltip={option.label} aria-label={option.label}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
2
2
|
import {
|
|
3
3
|
ReactFlow,
|
|
4
4
|
Background,
|
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
removeNodeAndCleanRefs,
|
|
52
52
|
resolveConnection,
|
|
53
53
|
} from './flowEditorOps'
|
|
54
|
+
import { placeFloatingPanel, type FloatingPlacement } from './flowMenuPlacement'
|
|
54
55
|
import {
|
|
55
56
|
computeAutoLayout,
|
|
56
57
|
targetsOf,
|
|
@@ -98,6 +99,14 @@ const QUICK_ADD_COLUMN_GAP = 320
|
|
|
98
99
|
const FLOW_KEY_PATTERN = /^[a-z0-9_]{2,40}$/
|
|
99
100
|
|
|
100
101
|
const EDGE_COLOR_LINEAR = '#94a3b8'
|
|
102
|
+
|
|
103
|
+
/** Enquadramento ao focar um fluxo pela aba. */
|
|
104
|
+
const FOCUS_MAX_ZOOM = 1
|
|
105
|
+
const FOCUS_PADDING = 0.2
|
|
106
|
+
const FOCUS_DURATION_MS = 400
|
|
107
|
+
|
|
108
|
+
/** Folga entre o "+" e o menu que ele abre. */
|
|
109
|
+
const QUICK_ADD_MENU_GAP = 12
|
|
101
110
|
const EDGE_COLOR_BRANCH = '#8b5cf6'
|
|
102
111
|
const EDGE_COLOR_FALLBACK = '#cbd5e1'
|
|
103
112
|
const EDGE_COLOR_LIVE = '#3b82f6'
|
|
@@ -262,6 +271,9 @@ export function FlowsWorkspace({
|
|
|
262
271
|
const [rfNodes, setRfNodes] = useState<Node[]>([])
|
|
263
272
|
const [flowInstance, setFlowInstance] = useState<ReactFlowInstance | null>(null)
|
|
264
273
|
const [pendingFocusNodeId, setPendingFocusNodeId] = useState<string | null>(null)
|
|
274
|
+
const [pendingFocusFlowKey, setPendingFocusFlowKey] = useState<string | null>(null)
|
|
275
|
+
const quickAddMenuRef = useRef<HTMLDivElement>(null)
|
|
276
|
+
const [quickAddPlacement, setQuickAddPlacement] = useState<FloatingPlacement | null>(null)
|
|
265
277
|
|
|
266
278
|
const reloadGraphs = useCallback(async () => {
|
|
267
279
|
try {
|
|
@@ -379,6 +391,10 @@ export function FlowsWorkspace({
|
|
|
379
391
|
if (others.some(isFlowDirty) && !window.confirm(labels.workspace.unsavedChangesConfirm)) return
|
|
380
392
|
setOpenFlowKeys(graphs ? mergedFlowKeysFrom(key, graphs) : [key])
|
|
381
393
|
if (editingRef && editingRef.flowKey !== key) setEditingRef(null)
|
|
394
|
+
// Trocar o fluxo primário não movia a câmera: a aba acendia, o canvas continuava onde
|
|
395
|
+
// estava e o fluxo escolhido ficava fora da tela — clicar em "Consórcio" parecia não fazer
|
|
396
|
+
// nada. O enquadramento espera os cards existirem (ver o efeito abaixo).
|
|
397
|
+
setPendingFocusFlowKey(key)
|
|
382
398
|
},
|
|
383
399
|
[openFlowKeys, isFlowDirty, editingRef, graphs, labels],
|
|
384
400
|
)
|
|
@@ -637,6 +653,51 @@ export function FlowsWorkspace({
|
|
|
637
653
|
setPendingFocusNodeId(null)
|
|
638
654
|
}, [pendingFocusNodeId, flowInstance, rfNodes])
|
|
639
655
|
|
|
656
|
+
// Enquadra o fluxo recém-focado. `fitView` restrito aos cards DELE, e não o `fitView` geral:
|
|
657
|
+
// o canvas mostra o fecho transitivo inteiro, e enquadrar tudo devolveria a mesma visão de
|
|
658
|
+
// sempre. `maxZoom` evita que um fluxo de dois nós encha a tela.
|
|
659
|
+
useEffect(() => {
|
|
660
|
+
if (!pendingFocusFlowKey || !flowInstance) return
|
|
661
|
+
const flowNodes = rfNodes.filter((node) => parseNamespacedId(node.id).flowKey === pendingFocusFlowKey)
|
|
662
|
+
if (flowNodes.length === 0) return
|
|
663
|
+
void flowInstance.fitView({
|
|
664
|
+
nodes: flowNodes.map((node) => ({ id: node.id })),
|
|
665
|
+
maxZoom: FOCUS_MAX_ZOOM,
|
|
666
|
+
padding: FOCUS_PADDING,
|
|
667
|
+
duration: FOCUS_DURATION_MS,
|
|
668
|
+
})
|
|
669
|
+
setPendingFocusFlowKey(null)
|
|
670
|
+
}, [pendingFocusFlowKey, flowInstance, rfNodes])
|
|
671
|
+
|
|
672
|
+
// O menu do "+" saía da tela quando o card estava perto da borda: a âncora era usada crua, sem
|
|
673
|
+
// consultar o tamanho da janela. Mede depois de montar e reposiciona antes da pintura.
|
|
674
|
+
useLayoutEffect(() => {
|
|
675
|
+
if (!quickAddFrom) {
|
|
676
|
+
setQuickAddPlacement(null)
|
|
677
|
+
return
|
|
678
|
+
}
|
|
679
|
+
const panel = quickAddMenuRef.current
|
|
680
|
+
if (!panel) return
|
|
681
|
+
const { x, y } = quickAddFrom.anchor
|
|
682
|
+
setQuickAddPlacement(
|
|
683
|
+
placeFloatingPanel({
|
|
684
|
+
anchor: { left: x, top: y, right: x, bottom: y },
|
|
685
|
+
panel: { width: panel.offsetWidth, height: panel.scrollHeight },
|
|
686
|
+
viewport: { width: window.innerWidth, height: window.innerHeight },
|
|
687
|
+
prefer: 'below',
|
|
688
|
+
gap: QUICK_ADD_MENU_GAP,
|
|
689
|
+
}),
|
|
690
|
+
)
|
|
691
|
+
}, [quickAddFrom])
|
|
692
|
+
|
|
693
|
+
const quickAddMenuStyle = {
|
|
694
|
+
left: quickAddPlacement?.left ?? 0,
|
|
695
|
+
top: quickAddPlacement?.top ?? 0,
|
|
696
|
+
maxHeight: quickAddPlacement?.maxHeight,
|
|
697
|
+
overflowY: 'auto' as const,
|
|
698
|
+
visibility: quickAddPlacement ? ('visible' as const) : ('hidden' as const),
|
|
699
|
+
}
|
|
700
|
+
|
|
640
701
|
const onNodesChange = useCallback((changes: NodeChange[]) => {
|
|
641
702
|
setRfNodes((current) => applyNodeChanges(changes, current))
|
|
642
703
|
}, [])
|
|
@@ -1045,8 +1106,9 @@ export function FlowsWorkspace({
|
|
|
1045
1106
|
<>
|
|
1046
1107
|
<div className="fixed inset-0 z-40" onClick={() => setQuickAddFrom(null)} />
|
|
1047
1108
|
<div
|
|
1109
|
+
ref={quickAddMenuRef}
|
|
1048
1110
|
className="fixed z-50 w-64 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1"
|
|
1049
|
-
style={
|
|
1111
|
+
style={quickAddMenuStyle}
|
|
1050
1112
|
>
|
|
1051
1113
|
<p className="px-3 py-1.5 text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">
|
|
1052
1114
|
{labels.quickAdd.title}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 Ada Technology. MIT License.
|
|
3
|
+
*
|
|
4
|
+
* O caso que originou isto: o submenu "Ação" aberto perto do rodapé mostrava metade da lista, com
|
|
5
|
+
* "Enviar catálogo de produtos" cortado pela borda — e sem rolagem, o item era inalcançável.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, it } from 'bun:test'
|
|
9
|
+
|
|
10
|
+
import { placeFloatingPanel } from './flowMenuPlacement'
|
|
11
|
+
|
|
12
|
+
const VIEWPORT = { width: 1200, height: 800 }
|
|
13
|
+
const rect = (left: number, top: number, width = 0, height = 0) => ({
|
|
14
|
+
left,
|
|
15
|
+
top,
|
|
16
|
+
right: left + width,
|
|
17
|
+
bottom: top + height,
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe('menu abaixo do ponto clicado', () => {
|
|
21
|
+
it('fica onde foi pedido quando há espaço', () => {
|
|
22
|
+
const placement = placeFloatingPanel({
|
|
23
|
+
anchor: rect(100, 100),
|
|
24
|
+
panel: { width: 256, height: 300 },
|
|
25
|
+
viewport: VIEWPORT,
|
|
26
|
+
prefer: 'below',
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
expect(placement.left).toBeGreaterThanOrEqual(100)
|
|
30
|
+
expect(placement.top).toBe(100)
|
|
31
|
+
expect(placement.maxHeight).toBe(300)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('encosta na borda direita em vez de sair dela', () => {
|
|
35
|
+
const placement = placeFloatingPanel({
|
|
36
|
+
anchor: rect(1150, 100),
|
|
37
|
+
panel: { width: 256, height: 300 },
|
|
38
|
+
viewport: VIEWPORT,
|
|
39
|
+
prefer: 'below',
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
expect(placement.left + 256).toBeLessThanOrEqual(VIEWPORT.width)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('sobe quando não cabe para baixo, em vez de vazar pelo rodapé', () => {
|
|
46
|
+
const placement = placeFloatingPanel({
|
|
47
|
+
anchor: rect(100, 700),
|
|
48
|
+
panel: { width: 256, height: 300 },
|
|
49
|
+
viewport: VIEWPORT,
|
|
50
|
+
prefer: 'below',
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
expect(placement.top + placement.maxHeight).toBeLessThanOrEqual(VIEWPORT.height)
|
|
54
|
+
expect(placement.top).toBeLessThan(700)
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('submenu ao lado do item', () => {
|
|
59
|
+
it('abre à direita quando cabe', () => {
|
|
60
|
+
const placement = placeFloatingPanel({
|
|
61
|
+
anchor: rect(300, 200, 256, 36),
|
|
62
|
+
panel: { width: 256, height: 240 },
|
|
63
|
+
viewport: VIEWPORT,
|
|
64
|
+
prefer: 'side',
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
expect(placement.left).toBeGreaterThanOrEqual(300 + 256)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('vira para a esquerda quando não cabe à direita', () => {
|
|
71
|
+
const placement = placeFloatingPanel({
|
|
72
|
+
anchor: rect(900, 200, 256, 36),
|
|
73
|
+
panel: { width: 256, height: 240 },
|
|
74
|
+
viewport: VIEWPORT,
|
|
75
|
+
prefer: 'side',
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
expect(placement.left).toBeLessThan(900)
|
|
79
|
+
expect(placement.left).toBeGreaterThanOrEqual(0)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('lista longa perto do rodapé ganha rolagem em vez de ser cortada', () => {
|
|
83
|
+
// O caso da captura: submenu de ações mais alto que o espaço abaixo do item.
|
|
84
|
+
const placement = placeFloatingPanel({
|
|
85
|
+
anchor: rect(300, 620, 256, 36),
|
|
86
|
+
panel: { width: 256, height: 520 },
|
|
87
|
+
viewport: VIEWPORT,
|
|
88
|
+
prefer: 'side',
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
expect(placement.top + placement.maxHeight).toBeLessThanOrEqual(VIEWPORT.height)
|
|
92
|
+
expect(placement.maxHeight).toBeLessThanOrEqual(520)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('painel mais alto que a tela cabe inteiro na tela, rolando por dentro', () => {
|
|
96
|
+
const placement = placeFloatingPanel({
|
|
97
|
+
anchor: rect(300, 400, 256, 36),
|
|
98
|
+
panel: { width: 256, height: 2000 },
|
|
99
|
+
viewport: VIEWPORT,
|
|
100
|
+
prefer: 'side',
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
expect(placement.top).toBeGreaterThanOrEqual(0)
|
|
104
|
+
expect(placement.maxHeight).toBeLessThan(VIEWPORT.height)
|
|
105
|
+
expect(placement.top + placement.maxHeight).toBeLessThanOrEqual(VIEWPORT.height)
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
describe('os menus do editor usam a conta acima', () => {
|
|
110
|
+
/**
|
|
111
|
+
* A função pura pode estar correta e ninguém chamá-la. Estes dois amarram o uso: sem eles a
|
|
112
|
+
* correção some no primeiro refactor e o menu volta a encostar na borda.
|
|
113
|
+
*/
|
|
114
|
+
it('o menu do "+" mede antes de posicionar, em vez de usar a âncora crua', async () => {
|
|
115
|
+
const content = await Bun.file(`${import.meta.dir}/FlowsWorkspace.tsx`).text()
|
|
116
|
+
|
|
117
|
+
expect(content).toContain('placeFloatingPanel')
|
|
118
|
+
expect(content).not.toContain('left: quickAddFrom.anchor.x + 12')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('o submenu não fica mais preso ao item por CSS', async () => {
|
|
122
|
+
const content = await Bun.file(`${import.meta.dir}/FlowPalette.tsx`).text()
|
|
123
|
+
|
|
124
|
+
expect(content).toContain('placeFloatingPanel')
|
|
125
|
+
// Verifica o uso em JSX, não a menção: o comentário do arquivo cita a classe antiga para
|
|
126
|
+
// explicar o que mudou, e casar com o texto solto reprovaria a própria explicação.
|
|
127
|
+
expect(content).not.toContain('className="absolute left-full')
|
|
128
|
+
expect(content).toContain('overflowY')
|
|
129
|
+
})
|
|
130
|
+
})
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 Ada Technology. MIT License.
|
|
3
|
+
*
|
|
4
|
+
* Onde um menu flutuante do editor pode aparecer sem sair da tela.
|
|
5
|
+
*
|
|
6
|
+
* Existe porque tanto o menu do "+" quanto os submenus dele eram posicionados por uma âncora crua:
|
|
7
|
+
* o menu em `fixed` na coordenada do clique, os submenus em `left-full top-0`. Card perto da borda
|
|
8
|
+
* direita jogava o menu para fora; item de ação perto do rodapé cortava a lista no meio, e não havia
|
|
9
|
+
* como rolar nem alcançar o resto — o menu ficava preso contra a borda.
|
|
10
|
+
*
|
|
11
|
+
* A conta é pura de propósito: é ela que decide se o operador consegue clicar na opção, e testá-la
|
|
12
|
+
* exige apenas números.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Retângulo do gatilho. Um clique é um retângulo de tamanho zero. */
|
|
16
|
+
export type AnchorRect = {
|
|
17
|
+
readonly left: number
|
|
18
|
+
readonly top: number
|
|
19
|
+
readonly right: number
|
|
20
|
+
readonly bottom: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type PanelSize = {
|
|
24
|
+
readonly width: number
|
|
25
|
+
readonly height: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type ViewportSize = {
|
|
29
|
+
readonly width: number
|
|
30
|
+
readonly height: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type FloatingPlacement = {
|
|
34
|
+
readonly left: number
|
|
35
|
+
readonly top: number
|
|
36
|
+
/** Teto de altura: acima disso o painel rola por dentro em vez de vazar pelo rodapé. */
|
|
37
|
+
readonly maxHeight: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type PlaceFloatingPanelParams = {
|
|
41
|
+
readonly anchor: AnchorRect
|
|
42
|
+
readonly panel: PanelSize
|
|
43
|
+
readonly viewport: ViewportSize
|
|
44
|
+
/** `side`: submenu sai ao lado do item; `below`: menu sai abaixo do ponto clicado. */
|
|
45
|
+
readonly prefer: 'side' | 'below'
|
|
46
|
+
readonly gap?: number
|
|
47
|
+
readonly margin?: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const DEFAULT_GAP = 4
|
|
51
|
+
const DEFAULT_MARGIN = 8
|
|
52
|
+
|
|
53
|
+
function clamp(value: number, minimum: number, maximum: number): number {
|
|
54
|
+
// `maximum` menor que `minimum` acontece com painel maior que a viewport: a margem de cima manda,
|
|
55
|
+
// porque cortar em cima esconde o começo da lista.
|
|
56
|
+
return Math.max(minimum, Math.min(value, Math.max(minimum, maximum)))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function placeFloatingPanel({
|
|
60
|
+
anchor,
|
|
61
|
+
panel,
|
|
62
|
+
viewport,
|
|
63
|
+
prefer,
|
|
64
|
+
gap = DEFAULT_GAP,
|
|
65
|
+
margin = DEFAULT_MARGIN,
|
|
66
|
+
}: PlaceFloatingPanelParams): FloatingPlacement {
|
|
67
|
+
const maxHeight = Math.min(panel.height, viewport.height - margin * 2)
|
|
68
|
+
|
|
69
|
+
let left: number
|
|
70
|
+
if (prefer === 'side') {
|
|
71
|
+
// Vira para a esquerda quando não cabe à direita — o inverso de escolher sempre um lado.
|
|
72
|
+
const toTheRight = anchor.right + gap
|
|
73
|
+
const toTheLeft = anchor.left - gap - panel.width
|
|
74
|
+
left = toTheRight + panel.width <= viewport.width - margin ? toTheRight : toTheLeft
|
|
75
|
+
} else {
|
|
76
|
+
left = anchor.left + gap
|
|
77
|
+
}
|
|
78
|
+
left = clamp(left, margin, viewport.width - margin - panel.width)
|
|
79
|
+
|
|
80
|
+
const top = prefer === 'side' ? anchor.top : anchor.bottom
|
|
81
|
+
return {
|
|
82
|
+
left,
|
|
83
|
+
top: clamp(top, margin, viewport.height - margin - maxHeight),
|
|
84
|
+
maxHeight,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -93,3 +93,34 @@ describe('contrato de customização', () => {
|
|
|
93
93
|
}
|
|
94
94
|
})
|
|
95
95
|
})
|
|
96
|
+
|
|
97
|
+
describe('focar um fluxo leva a câmera até ele', () => {
|
|
98
|
+
/**
|
|
99
|
+
* `focusFlow` trocava o fluxo primário e remesclava o canvas, mas nunca movia a viewport: a aba
|
|
100
|
+
* acendia, a tela continuava onde estava e o fluxo escolhido ficava fora do campo de visão — em
|
|
101
|
+
* "Consórcio", parado no início com o fluxo lá embaixo, clicar parecia não fazer nada.
|
|
102
|
+
*
|
|
103
|
+
* Teste de fonte, como os demais deste arquivo: não prova o enquadramento na tela, prova que o
|
|
104
|
+
* clique pede o enquadramento e que ele é restrito ao fluxo clicado.
|
|
105
|
+
*/
|
|
106
|
+
it('focar um fluxo agenda o enquadramento dele', async () => {
|
|
107
|
+
const content = await Bun.file(WORKSPACE_SOURCE).text()
|
|
108
|
+
|
|
109
|
+
expect(content).toContain('setPendingFocusFlowKey(key)')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('enquadra apenas os cards do fluxo focado, não o canvas inteiro', async () => {
|
|
113
|
+
// O canvas mostra o fecho transitivo inteiro. Um `fitView()` sem `nodes` devolveria a mesma
|
|
114
|
+
// visão de sempre — que é exatamente o defeito relatado.
|
|
115
|
+
const content = await Bun.file(WORKSPACE_SOURCE).text()
|
|
116
|
+
|
|
117
|
+
expect(content).toMatch(/fitView\(\{[\s\S]*?nodes: flowNodes\.map/)
|
|
118
|
+
expect(content).toMatch(/flowKey === pendingFocusFlowKey/)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('limita o zoom, para um fluxo de dois nós não encher a tela', async () => {
|
|
122
|
+
const content = await Bun.file(WORKSPACE_SOURCE).text()
|
|
123
|
+
|
|
124
|
+
expect(content).toContain('maxZoom: FOCUS_MAX_ZOOM')
|
|
125
|
+
})
|
|
126
|
+
})
|