android-midscene-automation 0.1.26 → 0.1.28
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/CHANGELOG.md +18 -0
- package/FAQ.md +14 -0
- package/README.md +39 -3
- package/bin/android-midscene-automation.js +71 -1
- package/package.json +7 -2
- package/remote-agent/index.ts +2 -11
- package/server/appium-recorder/ai-recognition.ts +67 -0
- package/server/appium-recorder/appium-runner.ts +335 -52
- package/server/appium-recorder/report.ts +297 -16
- package/server/appium-recorder/repository.ts +31 -4
- package/server/appium-recorder/routes.ts +31 -32
- package/server/appium-recorder/screenshot.ts +21 -0
- package/server/appium-recorder/script-export.ts +67 -0
- package/server/appium-recorder/tree-dump.ts +22 -0
- package/server/appium-recorder/visual-change.ts +184 -0
- package/server/config.ts +10 -0
- package/server/http-api.ts +4 -5
- package/src/App.vue +14 -7
- package/src/api.ts +1 -1
- package/src/appium-recorder/AppiumPage.vue +658 -162
- package/src/appium-recorder/ai-recognition.ts +16 -0
- package/src/appium-recorder/api.ts +20 -3
- package/src/appium-recorder/components/AiRecognitionTestDialog.vue +71 -0
- package/src/appium-recorder/components/FlowActionMenu.vue +206 -0
- package/src/appium-recorder/components/FlowCanvas.vue +16 -1
- package/src/appium-recorder/components/FlowNodeCard.vue +23 -37
- package/src/appium-recorder/components/FlowStepEditor.vue +152 -25
- package/src/appium-recorder/components/LongPressSettings.vue +58 -0
- package/src/appium-recorder/components/NestedConditionBranches.vue +55 -35
- package/src/appium-recorder/components/NewScriptDialog.vue +27 -0
- package/src/appium-recorder/components/NodeDetail.vue +2 -0
- package/src/appium-recorder/components/RecordedSteps.vue +24 -4
- package/src/appium-recorder/components/StageLogSettings.vue +17 -0
- package/src/appium-recorder/components/TextClickSettings.vue +19 -0
- package/src/appium-recorder/components/VisualChangeDialog.vue +151 -0
- package/src/appium-recorder/flow-copy.ts +47 -1
- package/src/appium-recorder/flow-graph.ts +147 -64
- package/src/appium-recorder/flow-labels.ts +35 -6
- package/src/appium-recorder/flow-normalize.ts +76 -0
- package/src/appium-recorder/flow-remove.ts +69 -0
- package/src/appium-recorder/long-press.ts +27 -0
- package/src/appium-recorder/native-control-state.ts +26 -0
- package/src/appium-recorder/stage-log.ts +21 -0
- package/src/appium-recorder/text-click.ts +9 -0
- package/src/appium-recorder/tree.ts +3 -0
- package/src/appium-recorder/types.ts +34 -2
- package/src/appium-recorder/visual-change-pairs.ts +147 -0
- package/src/appium-recorder/visual-change.ts +75 -0
- package/src/components/config/AppiumConfigPanel.vue +48 -0
- package/src/components/config/BaseConfigPanel.vue +90 -0
- package/src/components/config/MidsceneConfigPanel.vue +204 -0
- package/src/components/device/DevicePreviewPanel.vue +148 -4
- package/src/pages/ConfigPage.vue +107 -265
- package/src/style.css +43 -4
- package/src/types.ts +2 -0
- package/src/ui-refresh.css +5 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { ConfigForm } from '../../types';
|
|
3
|
+
|
|
4
|
+
defineProps<{
|
|
5
|
+
configForm: ConfigForm;
|
|
6
|
+
testingModelKey: string;
|
|
7
|
+
isSavingModelConfig: boolean;
|
|
8
|
+
testStatus?: string;
|
|
9
|
+
}>();
|
|
10
|
+
defineEmits<{ testModel: []; saveModelConfig: [] }>();
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<template>
|
|
14
|
+
<el-card shadow="never" class="config-module-card config-appium-card">
|
|
15
|
+
<template #header>
|
|
16
|
+
<div class="panel-header">
|
|
17
|
+
<span>节点模型配置</span>
|
|
18
|
+
<el-button type="primary" :loading="isSavingModelConfig" @click="$emit('saveModelConfig')">
|
|
19
|
+
保存模型配置
|
|
20
|
+
</el-button>
|
|
21
|
+
</div>
|
|
22
|
+
</template>
|
|
23
|
+
<section class="appium-model-form">
|
|
24
|
+
<div class="panel-header panel-header--sub">
|
|
25
|
+
<span>AI 识别模型</span>
|
|
26
|
+
<el-button :loading="testingModelKey === 'appium'" @click="$emit('testModel')">测试模型</el-button>
|
|
27
|
+
</div>
|
|
28
|
+
<el-form label-position="top">
|
|
29
|
+
<el-form-item label="Base URL">
|
|
30
|
+
<el-input v-model="configForm.appium.model.baseUrl" />
|
|
31
|
+
</el-form-item>
|
|
32
|
+
<el-form-item label="API Key">
|
|
33
|
+
<el-input v-model="configForm.appium.model.apiKey" show-password />
|
|
34
|
+
</el-form-item>
|
|
35
|
+
<el-form-item label="Model Name">
|
|
36
|
+
<el-input v-model="configForm.appium.model.name" placeholder="支持图片输入的模型" />
|
|
37
|
+
</el-form-item>
|
|
38
|
+
<el-form-item v-if="testStatus" label="测试结果">
|
|
39
|
+
<el-input :model-value="testStatus" readonly />
|
|
40
|
+
</el-form-item>
|
|
41
|
+
</el-form>
|
|
42
|
+
</section>
|
|
43
|
+
</el-card>
|
|
44
|
+
</template>
|
|
45
|
+
|
|
46
|
+
<style scoped>
|
|
47
|
+
.appium-model-form { max-width: 640px; }
|
|
48
|
+
</style>
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { Delete, Edit } from '@element-plus/icons-vue';
|
|
3
|
+
import type { AppPreset, ConfigForm } from '../../types';
|
|
4
|
+
|
|
5
|
+
defineProps<{
|
|
6
|
+
configForm: ConfigForm;
|
|
7
|
+
appPresets: AppPreset[];
|
|
8
|
+
appPresetForm: Pick<AppPreset, 'id' | 'name' | 'packageName'>;
|
|
9
|
+
isSavingModelConfig: boolean;
|
|
10
|
+
isSavingAppPreset: boolean;
|
|
11
|
+
}>();
|
|
12
|
+
|
|
13
|
+
defineEmits<{
|
|
14
|
+
saveModelConfig: [];
|
|
15
|
+
saveAppPreset: [];
|
|
16
|
+
editAppPreset: [app: AppPreset];
|
|
17
|
+
deleteAppPreset: [id: string];
|
|
18
|
+
}>();
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<template>
|
|
22
|
+
<div class="config-grid">
|
|
23
|
+
<el-card shadow="never" class="config-module-card">
|
|
24
|
+
<template #header>
|
|
25
|
+
<div class="panel-header">
|
|
26
|
+
<span>运行配置</span>
|
|
27
|
+
<el-button type="primary" :loading="isSavingModelConfig" @click="$emit('saveModelConfig')">
|
|
28
|
+
保存运行配置
|
|
29
|
+
</el-button>
|
|
30
|
+
</div>
|
|
31
|
+
</template>
|
|
32
|
+
|
|
33
|
+
<el-form label-position="top">
|
|
34
|
+
<el-form-item label="Android SDK 路径">
|
|
35
|
+
<el-input
|
|
36
|
+
v-model="configForm.runtime.androidSdkPath"
|
|
37
|
+
clearable
|
|
38
|
+
placeholder="留空时读取 ANDROID_SDK_ROOT、ANDROID_HOME 或系统默认路径"
|
|
39
|
+
/>
|
|
40
|
+
</el-form-item>
|
|
41
|
+
<el-form-item label="回放报告目录">
|
|
42
|
+
<el-input
|
|
43
|
+
v-model="configForm.runtime.reportOutputPath"
|
|
44
|
+
clearable
|
|
45
|
+
placeholder="留空时保存到启动目录下的 output"
|
|
46
|
+
/>
|
|
47
|
+
</el-form-item>
|
|
48
|
+
</el-form>
|
|
49
|
+
</el-card>
|
|
50
|
+
|
|
51
|
+
<el-card shadow="never" class="config-module-card">
|
|
52
|
+
<template #header>
|
|
53
|
+
<div class="panel-header">
|
|
54
|
+
<div class="panel-header__title">
|
|
55
|
+
<span>预设 App 参数</span>
|
|
56
|
+
<el-tag v-if="appPresetForm.id" size="small" type="warning">编辑中</el-tag>
|
|
57
|
+
</div>
|
|
58
|
+
<div class="panel-header__actions">
|
|
59
|
+
<el-button type="primary" :loading="isSavingAppPreset" @click="$emit('saveAppPreset')">
|
|
60
|
+
{{ appPresetForm.id ? '保存修改' : '保存 App' }}
|
|
61
|
+
</el-button>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
</template>
|
|
65
|
+
|
|
66
|
+
<el-form label-position="top">
|
|
67
|
+
<el-form-item label="App 名称">
|
|
68
|
+
<el-input v-model="appPresetForm.name" placeholder="例如:示例 App" />
|
|
69
|
+
</el-form-item>
|
|
70
|
+
<el-form-item label="App 包名">
|
|
71
|
+
<el-input v-model="appPresetForm.packageName" placeholder="例如:com.example.app" />
|
|
72
|
+
</el-form-item>
|
|
73
|
+
</el-form>
|
|
74
|
+
|
|
75
|
+
<div class="app-preset-list">
|
|
76
|
+
<div v-for="app in appPresets" :key="app.id" class="app-preset-row">
|
|
77
|
+
<div class="app-preset-row__main">
|
|
78
|
+
<strong>{{ app.name }}</strong>
|
|
79
|
+
<span>{{ app.packageName }}</span>
|
|
80
|
+
</div>
|
|
81
|
+
<div class="script-row__actions">
|
|
82
|
+
<el-button text size="small" :icon="Edit" @click="$emit('editAppPreset', app)" />
|
|
83
|
+
<el-button text size="small" :icon="Delete" @click="$emit('deleteAppPreset', app.id)" />
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
<el-empty v-if="!appPresets.length" description="暂无预设 App" />
|
|
87
|
+
</div>
|
|
88
|
+
</el-card>
|
|
89
|
+
</div>
|
|
90
|
+
</template>
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue';
|
|
3
|
+
import { QuestionFilled } from '@element-plus/icons-vue';
|
|
4
|
+
import {
|
|
5
|
+
codexMidsceneModelOptions,
|
|
6
|
+
midsceneModelPresets,
|
|
7
|
+
type MidsceneModelProvider,
|
|
8
|
+
} from '../../config/midscene-model-presets';
|
|
9
|
+
import type { ConfigForm } from '../../types';
|
|
10
|
+
|
|
11
|
+
const props = defineProps<{
|
|
12
|
+
configForm: ConfigForm;
|
|
13
|
+
testingModelKey: string;
|
|
14
|
+
isSavingModelConfig: boolean;
|
|
15
|
+
modelTestStatus: { midscene: string; scriptOptimizer: string };
|
|
16
|
+
}>();
|
|
17
|
+
|
|
18
|
+
const emit = defineEmits<{
|
|
19
|
+
testModel: [key: 'midscene' | 'scriptOptimizer'];
|
|
20
|
+
saveModelConfig: [];
|
|
21
|
+
updateMidsceneModelProvider: [provider: MidsceneModelProvider];
|
|
22
|
+
}>();
|
|
23
|
+
|
|
24
|
+
const activeMidsceneProvider = computed<MidsceneModelProvider>(() =>
|
|
25
|
+
props.configForm.midscene.model.provider === 'codex' ? 'codex' : 'custom',
|
|
26
|
+
);
|
|
27
|
+
const modelConfigGuideUrl = 'https://midscenejs.com/zh/model-common-config.html';
|
|
28
|
+
|
|
29
|
+
const normalizeBaseUrl = (value: string) => value.trim().replace(/\/+$/, '');
|
|
30
|
+
|
|
31
|
+
const updateMidsceneModelName = (value: string) => {
|
|
32
|
+
props.configForm.midscene.model.name = value;
|
|
33
|
+
const option = codexMidsceneModelOptions.find((item) => item.value === value);
|
|
34
|
+
if (option) {
|
|
35
|
+
props.configForm.midscene.model.family = option.family;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const updateCustomMidsceneBaseUrl = (value: string) => {
|
|
40
|
+
props.configForm.midscene.model.baseUrl = value;
|
|
41
|
+
const preset = midsceneModelPresets.find((item) => normalizeBaseUrl(item.baseUrl) === normalizeBaseUrl(value));
|
|
42
|
+
if (preset) {
|
|
43
|
+
props.configForm.midscene.model.name = preset.modelName;
|
|
44
|
+
props.configForm.midscene.model.family = preset.modelFamily;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const updateMidsceneProvider = (value: string) => {
|
|
49
|
+
if (value === 'custom' || value === 'codex') {
|
|
50
|
+
emit('updateMidsceneModelProvider', value);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const openModelConfigGuide = () => {
|
|
55
|
+
window.open(modelConfigGuideUrl, '_blank', 'noopener,noreferrer');
|
|
56
|
+
};
|
|
57
|
+
</script>
|
|
58
|
+
|
|
59
|
+
<template>
|
|
60
|
+
<el-card shadow="never" class="config-module-card">
|
|
61
|
+
<template #header>
|
|
62
|
+
<div class="panel-header">
|
|
63
|
+
<span>模型配置</span>
|
|
64
|
+
<el-button type="primary" :loading="isSavingModelConfig" @click="$emit('saveModelConfig')">
|
|
65
|
+
保存模型配置
|
|
66
|
+
</el-button>
|
|
67
|
+
</div>
|
|
68
|
+
</template>
|
|
69
|
+
|
|
70
|
+
<div class="config-model-grid">
|
|
71
|
+
<section>
|
|
72
|
+
<div class="panel-header panel-header--sub">
|
|
73
|
+
<span>Midscene 模型</span>
|
|
74
|
+
<el-button
|
|
75
|
+
:loading="testingModelKey === 'midscene'"
|
|
76
|
+
@click="$emit('testModel', 'midscene')"
|
|
77
|
+
>
|
|
78
|
+
测试模型
|
|
79
|
+
</el-button>
|
|
80
|
+
</div>
|
|
81
|
+
<el-form label-position="top">
|
|
82
|
+
<el-form-item label="接入方式">
|
|
83
|
+
<el-select
|
|
84
|
+
:model-value="activeMidsceneProvider"
|
|
85
|
+
@change="updateMidsceneProvider"
|
|
86
|
+
>
|
|
87
|
+
<el-option label="自定义提供方" value="custom" />
|
|
88
|
+
<el-option label="使用 Codex" value="codex" />
|
|
89
|
+
</el-select>
|
|
90
|
+
</el-form-item>
|
|
91
|
+
|
|
92
|
+
<template v-if="activeMidsceneProvider === 'custom'">
|
|
93
|
+
<el-form-item label="Base URL">
|
|
94
|
+
<el-input
|
|
95
|
+
:model-value="configForm.midscene.model.baseUrl"
|
|
96
|
+
placeholder="输入 Base URL,匹配已知提供方时自动填充模型"
|
|
97
|
+
@input="updateCustomMidsceneBaseUrl"
|
|
98
|
+
/>
|
|
99
|
+
</el-form-item>
|
|
100
|
+
<el-form-item label="API Key">
|
|
101
|
+
<el-input v-model="configForm.midscene.model.apiKey" show-password />
|
|
102
|
+
</el-form-item>
|
|
103
|
+
<el-form-item>
|
|
104
|
+
<template #label>
|
|
105
|
+
<span class="config-field-label">
|
|
106
|
+
<span>Model Name</span>
|
|
107
|
+
<el-tooltip content="查看模型填写参考" placement="top">
|
|
108
|
+
<el-button
|
|
109
|
+
class="config-field-help"
|
|
110
|
+
text
|
|
111
|
+
size="small"
|
|
112
|
+
:icon="QuestionFilled"
|
|
113
|
+
aria-label="查看 Model Name 填写参考"
|
|
114
|
+
@click.stop="openModelConfigGuide"
|
|
115
|
+
/>
|
|
116
|
+
</el-tooltip>
|
|
117
|
+
</span>
|
|
118
|
+
</template>
|
|
119
|
+
<el-input v-model="configForm.midscene.model.name" placeholder="例如:gpt-5.5" />
|
|
120
|
+
</el-form-item>
|
|
121
|
+
<el-form-item>
|
|
122
|
+
<template #label>
|
|
123
|
+
<span class="config-field-label">
|
|
124
|
+
<span>Model Family</span>
|
|
125
|
+
<el-tooltip content="查看模型填写参考" placement="top">
|
|
126
|
+
<el-button
|
|
127
|
+
class="config-field-help"
|
|
128
|
+
text
|
|
129
|
+
size="small"
|
|
130
|
+
:icon="QuestionFilled"
|
|
131
|
+
aria-label="查看 Model Family 填写参考"
|
|
132
|
+
@click.stop="openModelConfigGuide"
|
|
133
|
+
/>
|
|
134
|
+
</el-tooltip>
|
|
135
|
+
</span>
|
|
136
|
+
</template>
|
|
137
|
+
<el-input v-model="configForm.midscene.model.family" placeholder="例如:gpt-5" />
|
|
138
|
+
</el-form-item>
|
|
139
|
+
</template>
|
|
140
|
+
|
|
141
|
+
<template v-else>
|
|
142
|
+
<el-alert
|
|
143
|
+
class="config-form-alert"
|
|
144
|
+
type="info"
|
|
145
|
+
:closable="false"
|
|
146
|
+
title="复用 Codex 登录态,无需 API Key。请确保 codex 在 PATH 中可用,并已完成 codex login。"
|
|
147
|
+
/>
|
|
148
|
+
<el-form-item label="Base URL">
|
|
149
|
+
<el-input :model-value="configForm.midscene.model.baseUrl" readonly />
|
|
150
|
+
</el-form-item>
|
|
151
|
+
<el-form-item label="Model Name">
|
|
152
|
+
<el-select
|
|
153
|
+
:model-value="configForm.midscene.model.name"
|
|
154
|
+
@change="updateMidsceneModelName"
|
|
155
|
+
>
|
|
156
|
+
<el-option
|
|
157
|
+
v-for="option in codexMidsceneModelOptions"
|
|
158
|
+
:key="option.value"
|
|
159
|
+
:label="option.label"
|
|
160
|
+
:value="option.value"
|
|
161
|
+
/>
|
|
162
|
+
</el-select>
|
|
163
|
+
</el-form-item>
|
|
164
|
+
<el-form-item label="Model Family">
|
|
165
|
+
<el-select v-model="configForm.midscene.model.family" disabled>
|
|
166
|
+
<el-option label="gpt-5" value="gpt-5" />
|
|
167
|
+
</el-select>
|
|
168
|
+
</el-form-item>
|
|
169
|
+
</template>
|
|
170
|
+
<el-form-item v-if="modelTestStatus.midscene" label="测试结果">
|
|
171
|
+
<el-input :model-value="modelTestStatus.midscene" readonly />
|
|
172
|
+
</el-form-item>
|
|
173
|
+
</el-form>
|
|
174
|
+
</section>
|
|
175
|
+
|
|
176
|
+
<section>
|
|
177
|
+
<div class="panel-header panel-header--sub">
|
|
178
|
+
<span>脚本优化模型</span>
|
|
179
|
+
<el-button
|
|
180
|
+
:loading="testingModelKey === 'scriptOptimizer'"
|
|
181
|
+
@click="$emit('testModel', 'scriptOptimizer')"
|
|
182
|
+
>
|
|
183
|
+
测试模型
|
|
184
|
+
</el-button>
|
|
185
|
+
</div>
|
|
186
|
+
<el-form label-position="top">
|
|
187
|
+
<el-form-item label="Base URL">
|
|
188
|
+
<el-input v-model="configForm.scriptOptimizer.model.baseUrl" />
|
|
189
|
+
</el-form-item>
|
|
190
|
+
<el-form-item label="API Key">
|
|
191
|
+
<el-input v-model="configForm.scriptOptimizer.model.apiKey" show-password />
|
|
192
|
+
</el-form-item>
|
|
193
|
+
<el-form-item label="Model Name">
|
|
194
|
+
<el-input v-model="configForm.scriptOptimizer.model.name" />
|
|
195
|
+
</el-form-item>
|
|
196
|
+
<el-form-item v-if="modelTestStatus.scriptOptimizer" label="测试结果">
|
|
197
|
+
<el-input :model-value="modelTestStatus.scriptOptimizer" readonly />
|
|
198
|
+
</el-form-item>
|
|
199
|
+
</el-form>
|
|
200
|
+
</section>
|
|
201
|
+
</div>
|
|
202
|
+
|
|
203
|
+
</el-card>
|
|
204
|
+
</template>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { computed, shallowRef } from 'vue';
|
|
2
|
+
import { computed, shallowRef, watch } from 'vue';
|
|
3
3
|
import { Cpu, Refresh } from '@element-plus/icons-vue';
|
|
4
4
|
import type { AndroidDevice, DeviceAction } from '../../types';
|
|
5
5
|
|
|
@@ -11,6 +11,13 @@ type DeviceOverlayBounds = {
|
|
|
11
11
|
bottom: number;
|
|
12
12
|
};
|
|
13
13
|
|
|
14
|
+
type DeviceRegionSelection = {
|
|
15
|
+
x: number;
|
|
16
|
+
y: number;
|
|
17
|
+
width: number;
|
|
18
|
+
height: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
14
21
|
const props = defineProps<{
|
|
15
22
|
available: boolean;
|
|
16
23
|
devices: AndroidDevice[];
|
|
@@ -21,6 +28,9 @@ const props = defineProps<{
|
|
|
21
28
|
actions?: readonly DeviceAction[];
|
|
22
29
|
overlayBounds?: readonly DeviceOverlayBounds[];
|
|
23
30
|
selectedBounds?: DeviceOverlayBounds;
|
|
31
|
+
selectedRegion?: DeviceOverlayBounds;
|
|
32
|
+
regionSelection?: boolean;
|
|
33
|
+
regionDrawMode?: boolean;
|
|
24
34
|
deviceWidth?: number;
|
|
25
35
|
deviceHeight?: number;
|
|
26
36
|
}>();
|
|
@@ -32,6 +42,7 @@ const emit = defineEmits<{
|
|
|
32
42
|
previewError: [];
|
|
33
43
|
refreshPreview: [];
|
|
34
44
|
tap: [point: { x: number; y: number }];
|
|
45
|
+
regionSelect: [region: DeviceRegionSelection];
|
|
35
46
|
swipe: [gesture: {
|
|
36
47
|
startX: number;
|
|
37
48
|
startY: number;
|
|
@@ -43,11 +54,14 @@ const emit = defineEmits<{
|
|
|
43
54
|
|
|
44
55
|
const imageSize = shallowRef({ width: 0, height: 0 });
|
|
45
56
|
let pointerSession: {
|
|
57
|
+
mode: 'gesture' | 'region-draw' | 'region-move';
|
|
46
58
|
pointerId: number;
|
|
47
59
|
start: { x: number; y: number };
|
|
48
60
|
startClientX: number;
|
|
49
61
|
startClientY: number;
|
|
62
|
+
origin?: DeviceOverlayBounds;
|
|
50
63
|
} | null = null;
|
|
64
|
+
const draftRegion = shallowRef<DeviceOverlayBounds | null>(null);
|
|
51
65
|
const hasPreview = computed(() => Boolean(props.frameUrl || props.imageUrl));
|
|
52
66
|
const imageBoxStyle = computed(() => {
|
|
53
67
|
if (props.frameUrl) return {};
|
|
@@ -61,6 +75,12 @@ const overlayViewBox = computed(() => {
|
|
|
61
75
|
return width && height ? `0 0 ${width} ${height}` : '';
|
|
62
76
|
});
|
|
63
77
|
|
|
78
|
+
watch(() => props.regionSelection, (enabled) => {
|
|
79
|
+
if (enabled) return;
|
|
80
|
+
pointerSession = null;
|
|
81
|
+
draftRegion.value = null;
|
|
82
|
+
});
|
|
83
|
+
|
|
64
84
|
function updateImageSize(event: Event) {
|
|
65
85
|
const image = event.target as HTMLImageElement;
|
|
66
86
|
imageSize.value = { width: image.naturalWidth, height: image.naturalHeight };
|
|
@@ -97,18 +117,109 @@ function getDevicePoint(target: HTMLElement, clientX: number, clientY: number) {
|
|
|
97
117
|
};
|
|
98
118
|
}
|
|
99
119
|
|
|
120
|
+
function boundsFromPoints(
|
|
121
|
+
id: string,
|
|
122
|
+
start: { x: number; y: number },
|
|
123
|
+
end: { x: number; y: number },
|
|
124
|
+
) {
|
|
125
|
+
return {
|
|
126
|
+
id,
|
|
127
|
+
left: Math.min(start.x, end.x),
|
|
128
|
+
top: Math.min(start.y, end.y),
|
|
129
|
+
right: Math.max(start.x, end.x),
|
|
130
|
+
bottom: Math.max(start.y, end.y),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function pointInBounds(point: { x: number; y: number }, bounds?: DeviceOverlayBounds) {
|
|
135
|
+
if (!bounds) return false;
|
|
136
|
+
return point.x >= bounds.left
|
|
137
|
+
&& point.x <= bounds.right
|
|
138
|
+
&& point.y >= bounds.top
|
|
139
|
+
&& point.y <= bounds.bottom;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function clampRegion(bounds: DeviceOverlayBounds) {
|
|
143
|
+
const width = props.deviceWidth || imageSize.value.width;
|
|
144
|
+
const height = props.deviceHeight || imageSize.value.height;
|
|
145
|
+
const regionWidth = Math.max(1, bounds.right - bounds.left);
|
|
146
|
+
const regionHeight = Math.max(1, bounds.bottom - bounds.top);
|
|
147
|
+
const left = Math.max(0, Math.min(bounds.left, Math.max(0, width - regionWidth)));
|
|
148
|
+
const top = Math.max(0, Math.min(bounds.top, Math.max(0, height - regionHeight)));
|
|
149
|
+
return {
|
|
150
|
+
id: bounds.id,
|
|
151
|
+
left,
|
|
152
|
+
top,
|
|
153
|
+
right: left + regionWidth,
|
|
154
|
+
bottom: top + regionHeight,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function moveBounds(
|
|
159
|
+
origin: DeviceOverlayBounds,
|
|
160
|
+
start: { x: number; y: number },
|
|
161
|
+
end: { x: number; y: number },
|
|
162
|
+
) {
|
|
163
|
+
const dx = end.x - start.x;
|
|
164
|
+
const dy = end.y - start.y;
|
|
165
|
+
return clampRegion({
|
|
166
|
+
id: origin.id,
|
|
167
|
+
left: origin.left + dx,
|
|
168
|
+
top: origin.top + dy,
|
|
169
|
+
right: origin.right + dx,
|
|
170
|
+
bottom: origin.bottom + dy,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function emitRegion(bounds: DeviceOverlayBounds) {
|
|
175
|
+
const width = bounds.right - bounds.left;
|
|
176
|
+
const height = bounds.bottom - bounds.top;
|
|
177
|
+
if (width < 2 || height < 2) return;
|
|
178
|
+
emit('regionSelect', {
|
|
179
|
+
x: bounds.left,
|
|
180
|
+
y: bounds.top,
|
|
181
|
+
width,
|
|
182
|
+
height,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
100
186
|
function handlePointerDown(event: PointerEvent) {
|
|
101
187
|
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
|
102
188
|
const target = event.currentTarget as HTMLElement;
|
|
103
189
|
const point = getDevicePoint(target, event.clientX, event.clientY);
|
|
104
190
|
if (!point) return;
|
|
191
|
+
if (props.regionSelection) {
|
|
192
|
+
event.preventDefault();
|
|
193
|
+
event.stopPropagation();
|
|
194
|
+
}
|
|
105
195
|
target.setPointerCapture(event.pointerId);
|
|
196
|
+
const isMovingRegion = props.regionSelection
|
|
197
|
+
&& !props.regionDrawMode
|
|
198
|
+
&& pointInBounds(point, props.selectedRegion);
|
|
106
199
|
pointerSession = {
|
|
200
|
+
mode: props.regionSelection ? (isMovingRegion ? 'region-move' : 'region-draw') : 'gesture',
|
|
107
201
|
pointerId: event.pointerId,
|
|
108
202
|
start: point,
|
|
109
203
|
startClientX: event.clientX,
|
|
110
204
|
startClientY: event.clientY,
|
|
205
|
+
origin: isMovingRegion && props.selectedRegion ? { ...props.selectedRegion } : undefined,
|
|
111
206
|
};
|
|
207
|
+
if (props.regionSelection) {
|
|
208
|
+
draftRegion.value = pointerSession.origin || boundsFromPoints('draft-region', point, point);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function handlePointerMove(event: PointerEvent) {
|
|
213
|
+
if (!pointerSession || pointerSession.pointerId !== event.pointerId || pointerSession.mode === 'gesture') return;
|
|
214
|
+
const target = event.currentTarget as HTMLElement;
|
|
215
|
+
const point = getDevicePoint(target, event.clientX, event.clientY);
|
|
216
|
+
if (!point) return;
|
|
217
|
+
event.preventDefault();
|
|
218
|
+
if (pointerSession.mode === 'region-move' && pointerSession.origin) {
|
|
219
|
+
draftRegion.value = moveBounds(pointerSession.origin, pointerSession.start, point);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
draftRegion.value = boundsFromPoints('draft-region', pointerSession.start, point);
|
|
112
223
|
}
|
|
113
224
|
|
|
114
225
|
function handlePointerUp(event: PointerEvent) {
|
|
@@ -118,7 +229,17 @@ function handlePointerUp(event: PointerEvent) {
|
|
|
118
229
|
const session = pointerSession;
|
|
119
230
|
pointerSession = null;
|
|
120
231
|
if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);
|
|
121
|
-
if (!point)
|
|
232
|
+
if (!point) {
|
|
233
|
+
if (session.mode !== 'gesture') draftRegion.value = null;
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (session.mode !== 'gesture') {
|
|
238
|
+
const bounds = draftRegion.value || boundsFromPoints('selected-region', session.start, point);
|
|
239
|
+
draftRegion.value = null;
|
|
240
|
+
emitRegion(bounds);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
122
243
|
|
|
123
244
|
const moved = Math.hypot(
|
|
124
245
|
event.clientX - session.startClientX,
|
|
@@ -139,7 +260,10 @@ function handlePointerUp(event: PointerEvent) {
|
|
|
139
260
|
}
|
|
140
261
|
|
|
141
262
|
function handlePointerCancel(event: PointerEvent) {
|
|
142
|
-
if (pointerSession?.pointerId === event.pointerId)
|
|
263
|
+
if (pointerSession?.pointerId === event.pointerId) {
|
|
264
|
+
pointerSession = null;
|
|
265
|
+
draftRegion.value = null;
|
|
266
|
+
}
|
|
143
267
|
}
|
|
144
268
|
</script>
|
|
145
269
|
|
|
@@ -208,7 +332,9 @@ function handlePointerCancel(event: PointerEvent) {
|
|
|
208
332
|
>
|
|
209
333
|
<div
|
|
210
334
|
class="device-preview__surface"
|
|
335
|
+
:class="{ 'device-preview__surface--selecting': regionSelection }"
|
|
211
336
|
@pointerdown="handlePointerDown"
|
|
337
|
+
@pointermove="handlePointerMove"
|
|
212
338
|
@pointerup="handlePointerUp"
|
|
213
339
|
@pointercancel="handlePointerCancel"
|
|
214
340
|
/>
|
|
@@ -229,7 +355,7 @@ function handlePointerCancel(event: PointerEvent) {
|
|
|
229
355
|
@error="emit('previewError')"
|
|
230
356
|
/>
|
|
231
357
|
<svg
|
|
232
|
-
v-if="overlayViewBox && (overlayBounds?.length || selectedBounds)"
|
|
358
|
+
v-if="overlayViewBox && (overlayBounds?.length || selectedBounds || selectedRegion || draftRegion)"
|
|
233
359
|
class="device-preview__overlay"
|
|
234
360
|
:viewBox="overlayViewBox"
|
|
235
361
|
preserveAspectRatio="xMidYMid meet"
|
|
@@ -254,6 +380,24 @@ function handlePointerCancel(event: PointerEvent) {
|
|
|
254
380
|
:height="Math.max(0, selectedBounds.bottom - selectedBounds.top)"
|
|
255
381
|
vector-effect="non-scaling-stroke"
|
|
256
382
|
/>
|
|
383
|
+
<rect
|
|
384
|
+
v-if="selectedRegion && !draftRegion"
|
|
385
|
+
class="device-preview__selected-region"
|
|
386
|
+
:x="selectedRegion.left"
|
|
387
|
+
:y="selectedRegion.top"
|
|
388
|
+
:width="Math.max(0, selectedRegion.right - selectedRegion.left)"
|
|
389
|
+
:height="Math.max(0, selectedRegion.bottom - selectedRegion.top)"
|
|
390
|
+
vector-effect="non-scaling-stroke"
|
|
391
|
+
/>
|
|
392
|
+
<rect
|
|
393
|
+
v-if="draftRegion"
|
|
394
|
+
class="device-preview__draft-region"
|
|
395
|
+
:x="draftRegion.left"
|
|
396
|
+
:y="draftRegion.top"
|
|
397
|
+
:width="Math.max(0, draftRegion.right - draftRegion.left)"
|
|
398
|
+
:height="Math.max(0, draftRegion.bottom - draftRegion.top)"
|
|
399
|
+
vector-effect="non-scaling-stroke"
|
|
400
|
+
/>
|
|
257
401
|
</svg>
|
|
258
402
|
</div>
|
|
259
403
|
<div v-else class="device-preview__empty">
|