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.
Files changed (56) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/FAQ.md +14 -0
  3. package/README.md +39 -3
  4. package/bin/android-midscene-automation.js +71 -1
  5. package/package.json +7 -2
  6. package/remote-agent/index.ts +2 -11
  7. package/server/appium-recorder/ai-recognition.ts +67 -0
  8. package/server/appium-recorder/appium-runner.ts +335 -52
  9. package/server/appium-recorder/report.ts +297 -16
  10. package/server/appium-recorder/repository.ts +31 -4
  11. package/server/appium-recorder/routes.ts +31 -32
  12. package/server/appium-recorder/screenshot.ts +21 -0
  13. package/server/appium-recorder/script-export.ts +67 -0
  14. package/server/appium-recorder/tree-dump.ts +22 -0
  15. package/server/appium-recorder/visual-change.ts +184 -0
  16. package/server/config.ts +10 -0
  17. package/server/http-api.ts +4 -5
  18. package/src/App.vue +14 -7
  19. package/src/api.ts +1 -1
  20. package/src/appium-recorder/AppiumPage.vue +658 -162
  21. package/src/appium-recorder/ai-recognition.ts +16 -0
  22. package/src/appium-recorder/api.ts +20 -3
  23. package/src/appium-recorder/components/AiRecognitionTestDialog.vue +71 -0
  24. package/src/appium-recorder/components/FlowActionMenu.vue +206 -0
  25. package/src/appium-recorder/components/FlowCanvas.vue +16 -1
  26. package/src/appium-recorder/components/FlowNodeCard.vue +23 -37
  27. package/src/appium-recorder/components/FlowStepEditor.vue +152 -25
  28. package/src/appium-recorder/components/LongPressSettings.vue +58 -0
  29. package/src/appium-recorder/components/NestedConditionBranches.vue +55 -35
  30. package/src/appium-recorder/components/NewScriptDialog.vue +27 -0
  31. package/src/appium-recorder/components/NodeDetail.vue +2 -0
  32. package/src/appium-recorder/components/RecordedSteps.vue +24 -4
  33. package/src/appium-recorder/components/StageLogSettings.vue +17 -0
  34. package/src/appium-recorder/components/TextClickSettings.vue +19 -0
  35. package/src/appium-recorder/components/VisualChangeDialog.vue +151 -0
  36. package/src/appium-recorder/flow-copy.ts +47 -1
  37. package/src/appium-recorder/flow-graph.ts +147 -64
  38. package/src/appium-recorder/flow-labels.ts +35 -6
  39. package/src/appium-recorder/flow-normalize.ts +76 -0
  40. package/src/appium-recorder/flow-remove.ts +69 -0
  41. package/src/appium-recorder/long-press.ts +27 -0
  42. package/src/appium-recorder/native-control-state.ts +26 -0
  43. package/src/appium-recorder/stage-log.ts +21 -0
  44. package/src/appium-recorder/text-click.ts +9 -0
  45. package/src/appium-recorder/tree.ts +3 -0
  46. package/src/appium-recorder/types.ts +34 -2
  47. package/src/appium-recorder/visual-change-pairs.ts +147 -0
  48. package/src/appium-recorder/visual-change.ts +75 -0
  49. package/src/components/config/AppiumConfigPanel.vue +48 -0
  50. package/src/components/config/BaseConfigPanel.vue +90 -0
  51. package/src/components/config/MidsceneConfigPanel.vue +204 -0
  52. package/src/components/device/DevicePreviewPanel.vue +148 -4
  53. package/src/pages/ConfigPage.vue +107 -265
  54. package/src/style.css +43 -4
  55. package/src/types.ts +2 -0
  56. 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) return;
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) pointerSession = null;
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">