android-midscene-automation 0.1.21 → 0.1.22

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.
@@ -1,11 +1,12 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, onMounted, onUnmounted, reactive, shallowRef, watch } from 'vue';
3
3
  import { ElMessage, ElMessageBox } from 'element-plus';
4
- import { Check, CircleClose, CopyDocument, Delete, Document, Download, Refresh, Upload, VideoPlay } from '@element-plus/icons-vue';
4
+ import { Check, CircleClose, CopyDocument, Delete, Document, Download, Refresh, Upload, VideoPlay, View } from '@element-plus/icons-vue';
5
5
  import type { AndroidDevice, AppPreset, DeviceAction } from '../types';
6
6
  import DevicePreviewPanel from '../components/device/DevicePreviewPanel.vue';
7
7
  import {
8
8
  appiumScriptDownloadUrl,
9
+ clearAppiumDeviceAppData,
9
10
  deleteAppiumScript,
10
11
  getAppiumScripts,
11
12
  getAppiumTree,
@@ -17,6 +18,7 @@ import {
17
18
  tapAppiumDevice,
18
19
  } from './api';
19
20
  import ComponentTree from './components/ComponentTree.vue';
21
+ import FlowCanvas from './components/FlowCanvas.vue';
20
22
  import NodeDetail from './components/NodeDetail.vue';
21
23
  import RecordedSteps from './components/RecordedSteps.vue';
22
24
  import {
@@ -24,6 +26,8 @@ import {
24
26
  pasteFlowClipboard,
25
27
  type FlowClipboard,
26
28
  } from './flow-copy';
29
+ import { labelFlowStep } from './flow-labels';
30
+ import type { FlowActionGroup, InsertAction } from './flow-graph';
27
31
  import { findSmallestNodeAtPoint, flattenNodes, parseWindowHierarchy } from './tree';
28
32
  import type { AppiumNode, AppiumRecordedScript, AppiumRecordedStep } from './types';
29
33
 
@@ -55,6 +59,7 @@ type RecorderAction =
55
59
  | 'clearInput'
56
60
  | 'coordinateTap'
57
61
  | 'launchApp'
62
+ | 'clearAppData'
58
63
  | 'waitDisappear'
59
64
  | 'assertText'
60
65
  | 'longPress'
@@ -84,11 +89,14 @@ const insertableRecorderActions: RecorderAction[] = [
84
89
  'clearInput',
85
90
  'coordinateTap',
86
91
  'launchApp',
92
+ 'clearAppData',
87
93
  'waitDisappear',
88
94
  'assertText',
89
95
  'longPress',
90
96
  'pinch',
91
97
  ];
98
+ const readonlyFlowActionGroups: FlowActionGroup[] = [];
99
+ const readonlyFlowSelectedIndexes: number[] = [];
92
100
 
93
101
  const props = defineProps<{
94
102
  active: boolean;
@@ -132,6 +140,9 @@ const linkedScriptTargetId = shallowRef('');
132
140
  const linkedScriptInsertIndex = shallowRef<number | undefined>();
133
141
  const linkedScriptBranchTarget = shallowRef<BranchTarget | null>(null);
134
142
  const linkedScriptExpectedActivity = shallowRef('');
143
+ const linkedScriptPreviewVisible = shallowRef(false);
144
+ const linkedScriptPreviewScriptId = shallowRef('');
145
+ const linkedScriptPreviewResetToken = shallowRef(0);
135
146
  const loadingTree = shallowRef(false);
136
147
  const saving = shallowRef(false);
137
148
  const importingScript = shallowRef(false);
@@ -143,6 +154,7 @@ const activeReplayDeviceId = shallowRef('');
143
154
  const recordingTap = shallowRef(false);
144
155
  const resolvingNavigation = shallowRef(false);
145
156
  const launchingApp = shallowRef(false);
157
+ const executingAppStepId = shallowRef('');
146
158
  const pendingNavigation = shallowRef<{
147
159
  beforeActivity: string;
148
160
  afterActivity: string;
@@ -182,6 +194,10 @@ markDraftSaved();
182
194
 
183
195
  const selectedScript = computed(() => scripts.value.find((script) => script.id === selectedScriptId.value) || null);
184
196
  const linkableScripts = computed(() => scripts.value.filter((script) => script.id !== selectedScriptId.value));
197
+ const linkedScriptTarget = computed(() => scripts.value.find((script) => script.id === linkedScriptTargetId.value) || null);
198
+ const linkedScriptPreviewScript = computed(() => (
199
+ scripts.value.find((script) => script.id === linkedScriptPreviewScriptId.value) || null
200
+ ));
185
201
  const compatibleLinkableScripts = computed(() => linkableScripts.value.filter((script) => (
186
202
  script.appPackage === form.appPackage
187
203
  && (
@@ -640,7 +656,7 @@ async function refreshTree() {
640
656
 
641
657
  async function executeFlowStep(index: number) {
642
658
  const step = steps.value[index];
643
- if (!step || step.type !== 'launchApp') return;
659
+ if (!step || (step.type !== 'launchApp' && step.type !== 'clearAppData')) return;
644
660
  if (launchingApp.value) return;
645
661
  if (!selectedDeviceId.value) {
646
662
  ElMessage.warning('请选择设备');
@@ -648,29 +664,48 @@ async function executeFlowStep(index: number) {
648
664
  }
649
665
  const packageName = step.value?.trim() || form.appPackage;
650
666
  if (!packageName || !props.appPresets.some((app) => app.packageName === packageName)) {
651
- ElMessage.warning('启动节点没有匹配的预设 App');
667
+ ElMessage.warning('当前节点没有匹配的预设 App');
652
668
  return;
653
669
  }
654
670
 
671
+ if (step.type === 'clearAppData') {
672
+ const confirmed = await ElMessageBox.confirm(
673
+ 'Android 将清除该 App 的全部数据和缓存,登录状态与本地设置也会被重置。',
674
+ '确认清理 App 缓存',
675
+ {
676
+ confirmButtonText: '确认清理',
677
+ cancelButtonText: '取消',
678
+ type: 'warning',
679
+ center: true,
680
+ },
681
+ ).catch(() => false);
682
+ if (!confirmed) return;
683
+ }
684
+
655
685
  launchingApp.value = true;
686
+ executingAppStepId.value = step.id;
656
687
  try {
657
- await launchAppiumDeviceApp({
658
- deviceId: selectedDeviceId.value,
659
- packageName,
660
- });
688
+ if (step.type === 'clearAppData') {
689
+ await clearAppiumDeviceAppData({ deviceId: selectedDeviceId.value, packageName });
690
+ } else {
691
+ await launchAppiumDeviceApp({ deviceId: selectedDeviceId.value, packageName });
692
+ }
661
693
  props.refreshDevicePreview();
662
694
  await wait(800);
663
695
  await loadTreeSnapshot(true, true);
664
696
  props.refreshDevicePreview();
665
- if (scriptActivityMismatch.value) {
697
+ if (step.type === 'clearAppData') {
698
+ ElMessage.success('App 数据与缓存已清理');
699
+ } else if (scriptActivityMismatch.value) {
666
700
  ElMessage.warning(`App 已启动,当前页面仍为 ${currentActivity.value || '-'}`);
667
701
  } else {
668
702
  ElMessage.success('已进入脚本绑定页面,编辑锁定已解除');
669
703
  }
670
704
  } catch (error) {
671
- ElMessage.error(error instanceof Error ? error.message : '启动 App 失败');
705
+ ElMessage.error(error instanceof Error ? error.message : 'App 操作失败');
672
706
  } finally {
673
707
  launchingApp.value = false;
708
+ executingAppStepId.value = '';
674
709
  }
675
710
  }
676
711
 
@@ -867,6 +902,40 @@ function closeLinkedScriptDialog() {
867
902
  linkedScriptExpectedActivity.value = '';
868
903
  }
869
904
 
905
+ function openLinkedScriptPreview(scriptId = linkedScriptTargetId.value) {
906
+ const script = scripts.value.find((item) => item.id === scriptId);
907
+ if (!script) {
908
+ ElMessage.warning('请选择要预览的脚本');
909
+ return;
910
+ }
911
+ linkedScriptPreviewScriptId.value = script.id;
912
+ linkedScriptPreviewResetToken.value += 1;
913
+ linkedScriptPreviewVisible.value = true;
914
+ }
915
+
916
+ function previewLinkedScriptStep(index: number) {
917
+ openLinkedScriptPreview(steps.value[index]?.value || '');
918
+ }
919
+
920
+ function loadPreviewScriptForEditing() {
921
+ if (!linkedScriptPreviewScript.value) return;
922
+ linkedScriptPreviewVisible.value = false;
923
+ linkedScriptDialogVisible.value = false;
924
+ loadScript(linkedScriptPreviewScript.value);
925
+ }
926
+
927
+ function isReadonlyFlowActionDisabled(_action: InsertAction) {
928
+ return true;
929
+ }
930
+
931
+ function isReadonlyFlowAppExecutionDisabled(_action: 'launchApp' | 'clearAppData') {
932
+ return true;
933
+ }
934
+
935
+ function isReadonlyFlowCopySelected(_index: number) {
936
+ return false;
937
+ }
938
+
870
939
  async function addAction(
871
940
  action: RecorderAction,
872
941
  index?: number,
@@ -878,8 +947,12 @@ async function addAction(
878
947
  ElMessage.warning('启动 APP 节点只能添加一个');
879
948
  return;
880
949
  }
881
- if (action === 'launchApp' && index !== -1) {
882
- ElMessage.warning('启动 APP 只能从开始节点添加');
950
+ if (action === 'clearAppData' && steps.value.some((step) => step.type === 'clearAppData')) {
951
+ ElMessage.warning('清理 App 缓存节点只能添加一个');
952
+ return;
953
+ }
954
+ if ((action === 'launchApp' || action === 'clearAppData') && index !== -1) {
955
+ ElMessage.warning(`${action === 'launchApp' ? '启动 APP' : '清理 App 缓存'}只能从开始节点添加`);
883
956
  return;
884
957
  }
885
958
  if (action === 'runScript') {
@@ -947,11 +1020,20 @@ async function addAction(
947
1020
  }, index);
948
1021
  }
949
1022
  if (action === 'launchApp') {
1023
+ const clearStepIndex = steps.value.findIndex((step) => step.type === 'clearAppData');
950
1024
  return insertStep({
951
1025
  id: `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
952
1026
  type: 'launchApp',
953
1027
  label: `启动 APP ${form.appPackage}`,
954
1028
  value: form.appPackage,
1029
+ }, clearStepIndex >= 0 ? clearStepIndex : index);
1030
+ }
1031
+ if (action === 'clearAppData') {
1032
+ return insertStep({
1033
+ id: `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
1034
+ type: 'clearAppData',
1035
+ label: `清理 APP 缓存(含数据)${form.appPackage}`,
1036
+ value: form.appPackage,
955
1037
  }, index);
956
1038
  }
957
1039
  const node = getSelectedNode();
@@ -1056,62 +1138,6 @@ async function addBranchAction(index: number, branch: BranchName, action: Record
1056
1138
  }
1057
1139
  }
1058
1140
 
1059
- function connectBranchToNext(index: number, branch: BranchName) {
1060
- if (recordingLocked.value) return;
1061
- const condition = steps.value[index];
1062
- if (!condition) return;
1063
- const nextMainStep = steps.value.slice(index + 1).find((step) => !step.flow?.parentConditionId);
1064
- if (!nextMainStep) {
1065
- ElMessage.warning('当前判断节点后没有可连接的主流程节点');
1066
- return;
1067
- }
1068
-
1069
- const branchSteps = steps.value.filter((step) => (
1070
- step.flow?.parentConditionId === condition.id
1071
- && step.flow.parentBranch === branch
1072
- ));
1073
- if (!branchSteps.length) {
1074
- updateBranchTarget(condition.id, branch, nextMainStep.id);
1075
- } else {
1076
- const firstBranchStep = branchSteps[0];
1077
- const lastBranchStep = branchSteps[branchSteps.length - 1];
1078
- updateBranchTarget(condition.id, branch, firstBranchStep.id);
1079
- steps.value = steps.value.map((step) => (
1080
- step.id === lastBranchStep.id
1081
- ? {
1082
- ...step,
1083
- flow: { ...(step.flow || {}), successTargetId: nextMainStep.id },
1084
- }
1085
- : step
1086
- ));
1087
- }
1088
- ElMessage.success(`${branch === 'yes' ? '是' : '否'}分支已连接到「${nextMainStep.label}」`);
1089
- }
1090
-
1091
- function disconnectBranchFromNext(index: number, branch: BranchName) {
1092
- if (recordingLocked.value) return;
1093
- const condition = steps.value[index];
1094
- if (!condition) return;
1095
- const branchSteps = steps.value.filter((step) => (
1096
- step.flow?.parentConditionId === condition.id
1097
- && step.flow.parentBranch === branch
1098
- ));
1099
- if (!branchSteps.length) {
1100
- updateBranchTarget(condition.id, branch, '');
1101
- } else {
1102
- const lastBranchStep = branchSteps[branchSteps.length - 1];
1103
- steps.value = steps.value.map((step) => (
1104
- step.id === lastBranchStep.id
1105
- ? {
1106
- ...step,
1107
- flow: { ...(step.flow || {}), successTargetId: '' },
1108
- }
1109
- : step
1110
- ));
1111
- }
1112
- ElMessage.success(`${branch === 'yes' ? '是' : '否'}分支已取消连接`);
1113
- }
1114
-
1115
1141
  async function saveOnActivityChange() {
1116
1142
  const pending = pendingNavigation.value;
1117
1143
  if (!pending || resolvingNavigation.value) return;
@@ -1142,7 +1168,7 @@ async function saveOnActivityChange() {
1142
1168
  }
1143
1169
 
1144
1170
  function removeStep(index: number) {
1145
- if (recordingLocked.value) return;
1171
+ if (recordingBusy.value) return;
1146
1172
  const removed = steps.value[index];
1147
1173
  if (!removed) return;
1148
1174
  const nextBranchStep = removed.flow?.parentConditionId && removed.flow.parentBranch
@@ -1183,8 +1209,8 @@ function updateStep(index: number, step: AppiumRecordedStep) {
1183
1209
 
1184
1210
  async function editInputStep(index: number) {
1185
1211
  const step = steps.value[index];
1186
- if (!step || (step.type !== 'input' && step.type !== 'inputIfExists') || recordingLocked.value) return;
1187
- const input = await ElMessageBox.prompt('请输入新的录制文本', '修改输入内容', {
1212
+ if (!step || (step.type !== 'input' && step.type !== 'inputIfExists') || recordingBusy.value) return;
1213
+ const input = await ElMessageBox.prompt('', '修改输入内容', {
1188
1214
  inputValue: step.value || '',
1189
1215
  confirmButtonText: '保存',
1190
1216
  cancelButtonText: '取消',
@@ -1392,7 +1418,9 @@ watch(() => form.appPackage, (packageName) => {
1392
1418
  steps.value = steps.value.map((step) => (
1393
1419
  step.type === 'launchApp'
1394
1420
  ? { ...step, label: `启动 APP ${packageName}`, value: packageName }
1395
- : step
1421
+ : step.type === 'clearAppData'
1422
+ ? { ...step, label: `清理 APP 缓存(含数据)${packageName}`, value: packageName }
1423
+ : step
1396
1424
  ));
1397
1425
  });
1398
1426
 
@@ -1538,17 +1566,17 @@ watch(
1538
1566
  :steps="steps"
1539
1567
  :clipboard-count="flowClipboardCount"
1540
1568
  :disabled="recordingLocked"
1569
+ :remove-disabled="recordingBusy"
1541
1570
  :allowed-locked-actions="scriptActivityMismatch && !recordingBusy ? insertableRecorderActions : []"
1542
- :launching-step-id="launchingApp ? steps.find((step) => step.type === 'launchApp')?.id : ''"
1571
+ :launching-step-id="executingAppStepId"
1543
1572
  @remove="removeStep"
1544
1573
  @copy="copyFlowNodes"
1545
1574
  @paste="pasteFlowNodes"
1546
1575
  @add-delay="addDelayStep"
1547
1576
  @insert-action="(index, action) => addAction(action, index)"
1548
1577
  @insert-branch-action="addBranchAction"
1549
- @connect-next="connectBranchToNext"
1550
- @disconnect-next="disconnectBranchFromNext"
1551
1578
  @edit-input="editInputStep"
1579
+ @preview-linked-script="previewLinkedScriptStep"
1552
1580
  @execute-step="executeFlowStep"
1553
1581
  @update-step="updateStep"
1554
1582
  />
@@ -1648,19 +1676,28 @@ watch(
1648
1676
  <code class="appium-linked-script-activity">{{ linkedScriptExpectedActivity || '-' }}</code>
1649
1677
  </el-form-item>
1650
1678
  <el-form-item :label="linkedScriptBranchTarget ? '选择同一 App 的脚本(回放时等待入口 Activity)' : '选择入口 Activity 匹配的脚本'">
1651
- <el-select
1652
- v-model="linkedScriptTargetId"
1653
- filterable
1654
- placeholder="选择要连接的脚本"
1655
- class="appium-linked-script-select"
1656
- >
1657
- <el-option
1658
- v-for="script in compatibleLinkableScripts"
1659
- :key="script.id"
1660
- :label="`${script.name} · ${script.appActivity || '未绑定 Activity'} · ${script.steps.length} 步`"
1661
- :value="script.id"
1662
- />
1663
- </el-select>
1679
+ <div class="appium-linked-script-picker">
1680
+ <el-select
1681
+ v-model="linkedScriptTargetId"
1682
+ filterable
1683
+ placeholder="选择要连接的脚本"
1684
+ class="appium-linked-script-select"
1685
+ >
1686
+ <el-option
1687
+ v-for="script in compatibleLinkableScripts"
1688
+ :key="script.id"
1689
+ :label="`${script.name} · ${script.appActivity || '未绑定 Activity'} · ${script.steps.length} 步`"
1690
+ :value="script.id"
1691
+ />
1692
+ </el-select>
1693
+ <el-button
1694
+ :icon="View"
1695
+ :disabled="!linkedScriptTarget"
1696
+ @click="openLinkedScriptPreview()"
1697
+ >
1698
+ 预览
1699
+ </el-button>
1700
+ </div>
1664
1701
  </el-form-item>
1665
1702
  </el-form>
1666
1703
  <template #footer>
@@ -1669,6 +1706,57 @@ watch(
1669
1706
  </template>
1670
1707
  </el-dialog>
1671
1708
 
1709
+ <el-dialog
1710
+ v-model="linkedScriptPreviewVisible"
1711
+ :title="linkedScriptPreviewScript ? `预览脚本:${linkedScriptPreviewScript.name}` : '预览脚本'"
1712
+ width="86vw"
1713
+ top="6vh"
1714
+ class="appium-linked-script-preview-dialog"
1715
+ :z-index="4000"
1716
+ append-to-body
1717
+ >
1718
+ <template v-if="linkedScriptPreviewScript">
1719
+ <div class="appium-linked-script-preview-meta">
1720
+ <strong>{{ linkedScriptPreviewScript.name }}</strong>
1721
+ <span>
1722
+ {{ linkedScriptPreviewScript.appPackage || '-' }}
1723
+ · {{ linkedScriptPreviewScript.appActivity || '未绑定 Activity' }}
1724
+ · {{ linkedScriptPreviewScript.steps.length }} 步
1725
+ </span>
1726
+ </div>
1727
+ <FlowCanvas
1728
+ id="appium-linked-script-preview"
1729
+ class="appium-linked-script-preview-canvas"
1730
+ readonly
1731
+ disabled
1732
+ remove-disabled
1733
+ :steps="linkedScriptPreviewScript.steps"
1734
+ :expanded-step-index="null"
1735
+ :copy-mode="false"
1736
+ :selected-copy-indexes="readonlyFlowSelectedIndexes"
1737
+ :reset-view-token="linkedScriptPreviewResetToken"
1738
+ :start-action-groups="readonlyFlowActionGroups"
1739
+ :main-action-groups="readonlyFlowActionGroups"
1740
+ :can-open-insert-menu="false"
1741
+ :is-start-action-disabled="isReadonlyFlowActionDisabled"
1742
+ :is-insert-action-disabled="isReadonlyFlowActionDisabled"
1743
+ :is-app-execution-disabled="isReadonlyFlowAppExecutionDisabled"
1744
+ :label-step="labelFlowStep"
1745
+ :is-copy-selected="isReadonlyFlowCopySelected"
1746
+ />
1747
+ </template>
1748
+ <template #footer>
1749
+ <el-button @click="linkedScriptPreviewVisible = false">关闭</el-button>
1750
+ <el-button
1751
+ type="primary"
1752
+ :disabled="!linkedScriptPreviewScript"
1753
+ @click="loadPreviewScriptForEditing"
1754
+ >
1755
+ 加载后修改
1756
+ </el-button>
1757
+ </template>
1758
+ </el-dialog>
1759
+
1672
1760
  <el-dialog
1673
1761
  v-model="replayOutputDialogVisible"
1674
1762
  title="回放输出"
@@ -38,6 +38,10 @@ export function launchAppiumDeviceApp(input: { deviceId: string; packageName: st
38
38
  return postJson<{ success: boolean }>(`${APP_BASE}/api/appium-recorder/launch-app`, input);
39
39
  }
40
40
 
41
+ export function clearAppiumDeviceAppData(input: { deviceId: string; packageName: string }) {
42
+ return postJson<{ success: boolean }>(`${APP_BASE}/api/appium-recorder/clear-app-data`, input);
43
+ }
44
+
41
45
  export async function getAppiumScripts() {
42
46
  const response = await fetch(`${APP_BASE}/api/appium-recorder/scripts`);
43
47
  return readJson<{ scripts: AppiumRecordedScript[] }>(response);
@@ -0,0 +1,201 @@
1
+ <script setup lang="ts">
2
+ import { computed, nextTick, onBeforeUnmount, shallowRef, watch } from 'vue';
3
+ import { useVueFlow, VueFlow } from '@vue-flow/core';
4
+ import FlowNodeCard from './FlowNodeCard.vue';
5
+ import FlowRoundedEdge from './FlowRoundedEdge.vue';
6
+ import {
7
+ buildFlowGraph,
8
+ PASTE_COMMAND,
9
+ type FlowActionGroup,
10
+ type FlowBranch,
11
+ type InsertAction,
12
+ } from '../flow-graph';
13
+ import type { AppiumRecordedStep } from '../types';
14
+
15
+ const props = defineProps<{
16
+ id: string;
17
+ steps: AppiumRecordedStep[];
18
+ expandedStepIndex: number | null;
19
+ copyMode: boolean;
20
+ selectedCopyIndexes: number[];
21
+ disabled?: boolean;
22
+ removeDisabled?: boolean;
23
+ readonly?: boolean;
24
+ launchingStepId?: string;
25
+ clipboardCount?: number;
26
+ resetViewToken?: number;
27
+ startActionGroups: FlowActionGroup[];
28
+ mainActionGroups: FlowActionGroup[];
29
+ canOpenInsertMenu: boolean;
30
+ isStartActionDisabled: (action: InsertAction) => boolean;
31
+ isInsertActionDisabled: (action: InsertAction) => boolean;
32
+ isAppExecutionDisabled: (action: 'launchApp' | 'clearAppData') => boolean;
33
+ labelStep: (step: AppiumRecordedStep) => { title: string; meta: string; note?: string };
34
+ isCopySelected: (index: number) => boolean;
35
+ }>();
36
+
37
+ const emit = defineEmits<{
38
+ nodeClick: [index: number];
39
+ copy: [indexes: number[]];
40
+ paste: [index: number, branch?: FlowBranch];
41
+ remove: [index: number];
42
+ insertAction: [index: number, action: InsertAction];
43
+ insertBranchAction: [index: number, branch: FlowBranch, action: InsertAction];
44
+ editInput: [index: number];
45
+ previewLinkedScript: [index: number];
46
+ executeStep: [index: number];
47
+ updateStep: [index: number, step: AppiumRecordedStep];
48
+ }>();
49
+
50
+ const measuredNodeHeights = shallowRef<Record<string, number>>({});
51
+ const { fitView, updateNodeInternals } = useVueFlow(props.id);
52
+ const paneReady = shallowRef(false);
53
+ let internalsFrame = 0;
54
+ let resetViewFrame = 0;
55
+ let pendingResetView = false;
56
+
57
+ const graph = computed(() => buildFlowGraph(props.steps, {
58
+ expandedStepIndex: props.expandedStepIndex,
59
+ selectedCopyIndexes: props.selectedCopyIndexes,
60
+ copyMode: props.copyMode,
61
+ disabled: props.disabled,
62
+ removeDisabled: props.removeDisabled,
63
+ launchingStepId: props.launchingStepId,
64
+ clipboardCount: props.clipboardCount,
65
+ canOpenInsertMenu: props.canOpenInsertMenu,
66
+ isStartActionDisabled: props.isStartActionDisabled,
67
+ isInsertActionDisabled: props.isInsertActionDisabled,
68
+ isAppExecutionDisabled: props.isAppExecutionDisabled,
69
+ startActionGroups: props.startActionGroups,
70
+ mainActionGroups: props.mainActionGroups,
71
+ measuredNodeHeights: measuredNodeHeights.value,
72
+ labelStep: props.labelStep,
73
+ isCopySelected: props.isCopySelected,
74
+ }));
75
+
76
+ function handleNodeResize(payload: { id: string; height: number }) {
77
+ const height = Math.ceil(payload.height) + 6;
78
+ if (!height || Math.abs((measuredNodeHeights.value[payload.id] || 0) - height) < 1) return;
79
+ measuredNodeHeights.value = {
80
+ ...measuredNodeHeights.value,
81
+ [payload.id]: height,
82
+ };
83
+ void nextTick(scheduleNodeInternalsUpdate);
84
+ if (props.readonly) void nextTick(() => scheduleResetView());
85
+ }
86
+
87
+ function scheduleNodeInternalsUpdate() {
88
+ if (internalsFrame) window.cancelAnimationFrame(internalsFrame);
89
+ internalsFrame = window.requestAnimationFrame(() => {
90
+ internalsFrame = 0;
91
+ updateNodeInternals(graph.value.nodes.map((node) => node.id));
92
+ });
93
+ }
94
+
95
+ function scheduleResetView(attempt = 0) {
96
+ if (!paneReady.value) {
97
+ pendingResetView = true;
98
+ return;
99
+ }
100
+ if (resetViewFrame) window.cancelAnimationFrame(resetViewFrame);
101
+ resetViewFrame = window.requestAnimationFrame(async () => {
102
+ resetViewFrame = 0;
103
+ updateNodeInternals(graph.value.nodes.map((node) => node.id));
104
+ const fitted = await fitView({
105
+ padding: 0.18,
106
+ minZoom: 0.35,
107
+ maxZoom: 1.8,
108
+ duration: attempt ? 0 : 240,
109
+ });
110
+ if (!fitted && attempt < 8) scheduleResetView(attempt + 1);
111
+ });
112
+ }
113
+
114
+ function handlePaneReady() {
115
+ paneReady.value = true;
116
+ if (!pendingResetView && !props.readonly) return;
117
+ pendingResetView = false;
118
+ void nextTick(() => scheduleResetView());
119
+ }
120
+
121
+ watch(() => props.steps.map((step) => step.id).join('|'), () => {
122
+ measuredNodeHeights.value = {};
123
+ void nextTick(scheduleNodeInternalsUpdate);
124
+ });
125
+
126
+ watch(graph, () => {
127
+ void nextTick(scheduleNodeInternalsUpdate);
128
+ });
129
+
130
+ watch(() => props.resetViewToken, () => {
131
+ void nextTick(scheduleResetView);
132
+ });
133
+
134
+ onBeforeUnmount(() => {
135
+ if (internalsFrame) window.cancelAnimationFrame(internalsFrame);
136
+ if (resetViewFrame) window.cancelAnimationFrame(resetViewFrame);
137
+ pendingResetView = false;
138
+ });
139
+
140
+ function handleInsert(payload: {
141
+ afterIndex: number;
142
+ action: InsertAction | typeof PASTE_COMMAND;
143
+ branch?: FlowBranch;
144
+ conditionIndex?: number;
145
+ }) {
146
+ if (payload.action === PASTE_COMMAND) {
147
+ emit('paste', payload.conditionIndex ?? payload.afterIndex, payload.branch);
148
+ return;
149
+ }
150
+ if (payload.branch && payload.conditionIndex !== undefined) {
151
+ emit('insertBranchAction', payload.conditionIndex, payload.branch, payload.action);
152
+ return;
153
+ }
154
+ emit('insertAction', payload.afterIndex, payload.action);
155
+ }
156
+ </script>
157
+
158
+ <template>
159
+ <div class="appium-flow-canvas appium-flow-canvas--vue">
160
+ <VueFlow
161
+ :id="id"
162
+ class="appium-vue-flow"
163
+ :nodes="graph.nodes"
164
+ :edges="graph.edges"
165
+ :fit-view-on-init="true"
166
+ :min-zoom="0.35"
167
+ :max-zoom="1.8"
168
+ :nodes-draggable="false"
169
+ :nodes-connectable="false"
170
+ :elements-selectable="false"
171
+ :pan-on-drag="true"
172
+ :zoom-on-scroll="true"
173
+ zoom-activation-key-code="Control"
174
+ :zoom-on-double-click="false"
175
+ :prevent-scrolling="false"
176
+ no-drag-class-name="nodrag"
177
+ no-pan-class-name="nopan"
178
+ @pane-ready="handlePaneReady"
179
+ >
180
+ <template #node-flow-node="{ id: nodeId, data }">
181
+ <FlowNodeCard
182
+ :node-id="nodeId"
183
+ :data="data"
184
+ :readonly="props.readonly"
185
+ @node-click="emit('nodeClick', $event)"
186
+ @copy="emit('copy', [$event])"
187
+ @remove="emit('remove', $event)"
188
+ @edit-input="emit('editInput', $event)"
189
+ @execute="emit('executeStep', $event)"
190
+ @insert="handleInsert"
191
+ @update-step="emit('updateStep', $event.index, $event.step)"
192
+ @preview-linked-script="emit('previewLinkedScript', $event)"
193
+ @resize="handleNodeResize"
194
+ />
195
+ </template>
196
+ <template #edge-flow-rounded="edgeProps">
197
+ <FlowRoundedEdge v-bind="edgeProps" />
198
+ </template>
199
+ </VueFlow>
200
+ </div>
201
+ </template>