xw-devtool-cli 1.0.40 → 1.0.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/README.md +4 -1
- package/README_EN.md +2 -1
- package/package.json +4 -1
- package/src/commands/imgMerge.js +285 -0
- package/src/index.js +68 -3
- package/src/locales/en.js +43 -1
- package/src/locales/zh.js +43 -1
- package/src/utils/fileDialog.js +15 -0
package/README.md
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
- **图片格式转换**:支持 PNG、JPG、WebP、ICO 格式互转,可调整压缩质量。生成 ICO 时支持自定义尺寸(逗号分隔),留空则默认生成 `256` 尺寸。输出文件名默认带时间戳,避免覆盖。
|
|
14
14
|
- **图片 ↔ Base64**:支持图片转 Base64 字符串,以及 Base64 还原为图片文件。
|
|
15
15
|
- **图片分割**:支持网格等分(自定义行/列数)或自定义分割线(像素/百分比),自动生成分割后的图片文件。
|
|
16
|
+
- **图片拼接**:支持“选择目录”或“选择多张图片”两种输入方式;在对话框中可一次多选多张图片,可选**水平拼接**或**垂直拼接**,输出支持选择指定目录(对话框或手动输入),默认输出文件名带时间戳并自动复制输出路径。
|
|
16
17
|
- **图片主色识别**:提取图片主色调(Hex/RGB/HSL/HSV),默认复制 Hex 到剪贴板,支持保存详细信息到文件。
|
|
17
18
|
- **颜色吸取**:从图片中吸取单像素颜色或区域平均颜色,支持 px/% 坐标输入,结果显示颜色条并自动复制 Hex/Hex8。
|
|
18
19
|
- **屏幕取色**:实时显示鼠标所在位置的颜色值(Hex/RGB)。
|
|
@@ -96,7 +97,7 @@ xw-devtool --zh # 中文启动
|
|
|
96
97
|
xw-devtool --en # 英文启动 (Start in English)
|
|
97
98
|
```
|
|
98
99
|
|
|
99
|
-
|
|
100
|
+
启动后将显示交互式菜单,通过键盘输入对应的数字或字母选择功能,也支持直接输入关键字搜索(如 `json`、`图片`、`git`):
|
|
100
101
|
|
|
101
102
|
```text
|
|
102
103
|
=================================
|
|
@@ -446,6 +447,8 @@ v. Git Helper (Branch/Commit Template) w. Settings / Language
|
|
|
446
447
|
=================================
|
|
447
448
|
```
|
|
448
449
|
|
|
450
|
+
You can enter a feature key directly, or type a keyword (e.g. `json`, `image`, `git`) to search features and then choose from matched results.
|
|
451
|
+
|
|
449
452
|
### 📖 Detailed Usage
|
|
450
453
|
|
|
451
454
|
#### 1. Image <-> Base64
|
package/README_EN.md
CHANGED
|
@@ -13,6 +13,7 @@ Key features include: Base64 encoding/decoding, image format conversion, image <
|
|
|
13
13
|
- **Format Conversion**: Convert between PNG, JPG, WebP, with adjustable compression quality.
|
|
14
14
|
- **Image ↔ Base64**: Convert images to Base64 strings and vice versa.
|
|
15
15
|
- **Image Splitter**: Split images into grid (rows/cols) or custom split lines (pixels/percentage).
|
|
16
|
+
- **Image Merger**: Supports two input modes (folder or multiple images); the dialog mode supports selecting multiple images at once. You can choose **horizontal** or **vertical** merge, and choose a specific output directory (dialog or manual input). Default output filename includes timestamp, and output path is copied to clipboard.
|
|
16
17
|
- **Placeholder Image**: Quickly generate placeholder images with custom size, color, and text.
|
|
17
18
|
- **Color Picker**: Pick a single pixel color or area average from an image; supports px/% coordinates; shows preview bar and auto-copies Hex/Hex8.
|
|
18
19
|
- **Screen Picker**: Pick color directly from the screen at the mouse position; move the cursor and press Enter to sample; supports alpha preview.
|
|
@@ -93,7 +94,7 @@ xw-devtool --en # Start in English
|
|
|
93
94
|
xw-devtool --zh # Start in Chinese
|
|
94
95
|
```
|
|
95
96
|
|
|
96
|
-
The interactive menu will appear:
|
|
97
|
+
The interactive menu will appear. You can enter a feature key directly, or type a keyword (e.g. `json`, `image`, `git`) to search and then choose from matched results:
|
|
97
98
|
|
|
98
99
|
```text
|
|
99
100
|
=================================
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "xw-devtool-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.42",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "基于node的开发者助手cli",
|
|
6
6
|
"main": "index.js",
|
|
@@ -54,6 +54,9 @@
|
|
|
54
54
|
"screen-mark",
|
|
55
55
|
"pixel-distance",
|
|
56
56
|
"image-compress",
|
|
57
|
+
"image-merge",
|
|
58
|
+
"merge-images",
|
|
59
|
+
"image-stitch",
|
|
57
60
|
"png-compress",
|
|
58
61
|
"jpg-compress",
|
|
59
62
|
"webp-compress",
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import inquirer from 'inquirer';
|
|
4
|
+
import sharp from 'sharp';
|
|
5
|
+
import i18next from '../i18n.js';
|
|
6
|
+
import { selectFromMenu } from '../utils/menu.js';
|
|
7
|
+
import { selectFiles, selectFolder } from '../utils/fileDialog.js';
|
|
8
|
+
import { copy } from '../utils/clipboard.js';
|
|
9
|
+
import { defaultFileName } from '../utils/output.js';
|
|
10
|
+
|
|
11
|
+
const SUPPORTED_EXT = new Set(['.png', '.jpg', '.jpeg', '.webp', '.bmp', '.tif', '.tiff', '.gif']);
|
|
12
|
+
|
|
13
|
+
function isImageFile(filePath) {
|
|
14
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
15
|
+
return SUPPORTED_EXT.has(ext);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function listImagesInDir(dir) {
|
|
19
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
20
|
+
return entries
|
|
21
|
+
.filter((entry) => entry.isFile() && isImageFile(entry.name))
|
|
22
|
+
.map((entry) => path.join(dir, entry.name))
|
|
23
|
+
.sort((a, b) => path.basename(a).localeCompare(path.basename(b), undefined, { numeric: true, sensitivity: 'base' }));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseManualImagePaths(input) {
|
|
27
|
+
return Array.from(new Set(
|
|
28
|
+
input
|
|
29
|
+
.split(/[\r\n,]+/)
|
|
30
|
+
.map((p) => p.trim())
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function pickFolderPath() {
|
|
36
|
+
const method = await selectFromMenu(
|
|
37
|
+
i18next.t('imgMerge.inputMethod'),
|
|
38
|
+
[
|
|
39
|
+
{ name: i18next.t('imgMerge.folderDialog'), value: 'dialog' },
|
|
40
|
+
{ name: i18next.t('imgMerge.folderManual'), value: 'manual' }
|
|
41
|
+
],
|
|
42
|
+
true,
|
|
43
|
+
i18next.t('common.back')
|
|
44
|
+
);
|
|
45
|
+
if (method === '__BACK__') return null;
|
|
46
|
+
|
|
47
|
+
if (method === 'dialog') {
|
|
48
|
+
const selected = selectFolder();
|
|
49
|
+
if (!selected) {
|
|
50
|
+
console.log(i18next.t('imgMerge.dialogFail'));
|
|
51
|
+
return await pickFolderPath();
|
|
52
|
+
}
|
|
53
|
+
return selected;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const { folderPath } = await inquirer.prompt([
|
|
57
|
+
{
|
|
58
|
+
type: 'input',
|
|
59
|
+
name: 'folderPath',
|
|
60
|
+
message: i18next.t('imgMerge.enterFolderPath'),
|
|
61
|
+
validate: (input) => {
|
|
62
|
+
if (!fs.existsSync(input)) return i18next.t('imgMerge.notExist');
|
|
63
|
+
if (!fs.statSync(input).isDirectory()) return i18next.t('imgMerge.notDirectory');
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
]);
|
|
68
|
+
return folderPath;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function pickMultiImageFiles() {
|
|
72
|
+
const method = await selectFromMenu(
|
|
73
|
+
i18next.t('imgMerge.inputMethod'),
|
|
74
|
+
[
|
|
75
|
+
{ name: i18next.t('imgMerge.multiDialog'), value: 'dialog' },
|
|
76
|
+
{ name: i18next.t('imgMerge.multiManual'), value: 'manual' }
|
|
77
|
+
],
|
|
78
|
+
true,
|
|
79
|
+
i18next.t('common.back')
|
|
80
|
+
);
|
|
81
|
+
if (method === '__BACK__') return null;
|
|
82
|
+
|
|
83
|
+
if (method === 'dialog') {
|
|
84
|
+
const files = selectFiles('Image Files|*.png;*.jpg;*.jpeg;*.webp;*.bmp;*.tif;*.tiff;*.gif|All Files|*.*');
|
|
85
|
+
if (!files || files.length === 0) {
|
|
86
|
+
console.log(i18next.t('imgMerge.dialogFail'));
|
|
87
|
+
return await pickMultiImageFiles();
|
|
88
|
+
}
|
|
89
|
+
return Array.from(new Set(files));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const { filesInput } = await inquirer.prompt([
|
|
93
|
+
{
|
|
94
|
+
type: 'input',
|
|
95
|
+
name: 'filesInput',
|
|
96
|
+
message: i18next.t('imgMerge.enterImagePaths')
|
|
97
|
+
}
|
|
98
|
+
]);
|
|
99
|
+
return parseManualImagePaths(filesInput);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function pickOutputPath(baseDir) {
|
|
103
|
+
const outputMethod = await selectFromMenu(
|
|
104
|
+
i18next.t('imgMerge.selectOutput'),
|
|
105
|
+
[
|
|
106
|
+
{ name: i18next.t('imgMerge.outputDefault'), value: 'default' },
|
|
107
|
+
{ name: i18next.t('imgMerge.outputFolderDialog'), value: 'folderDialog' },
|
|
108
|
+
{ name: i18next.t('imgMerge.outputFolderManual'), value: 'folderManual' },
|
|
109
|
+
{ name: i18next.t('imgMerge.outputFileManual'), value: 'fileManual' }
|
|
110
|
+
],
|
|
111
|
+
true,
|
|
112
|
+
i18next.t('common.back')
|
|
113
|
+
);
|
|
114
|
+
if (outputMethod === '__BACK__') return null;
|
|
115
|
+
|
|
116
|
+
if (outputMethod === 'default') {
|
|
117
|
+
return path.join(baseDir, defaultFileName('img-merge', 'png'));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (outputMethod === 'folderDialog') {
|
|
121
|
+
const selected = selectFolder();
|
|
122
|
+
if (!selected) {
|
|
123
|
+
console.log(i18next.t('imgMerge.dialogFail'));
|
|
124
|
+
return await pickOutputPath(baseDir);
|
|
125
|
+
}
|
|
126
|
+
return path.join(selected, defaultFileName('img-merge', 'png'));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (outputMethod === 'folderManual') {
|
|
130
|
+
const { outputDir } = await inquirer.prompt([
|
|
131
|
+
{
|
|
132
|
+
type: 'input',
|
|
133
|
+
name: 'outputDir',
|
|
134
|
+
message: i18next.t('imgMerge.enterOutputDir'),
|
|
135
|
+
default: baseDir,
|
|
136
|
+
validate: (input) => {
|
|
137
|
+
const dir = input.trim();
|
|
138
|
+
if (!dir) return i18next.t('imgMerge.outputPathRequired');
|
|
139
|
+
if (fs.existsSync(dir) && !fs.statSync(dir).isDirectory()) {
|
|
140
|
+
return i18next.t('imgMerge.notDirectory');
|
|
141
|
+
}
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
]);
|
|
146
|
+
return path.join(outputDir.trim(), defaultFileName('img-merge', 'png'));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const defaultPath = path.join(baseDir, defaultFileName('img-merge', 'png'));
|
|
150
|
+
const { outputPath } = await inquirer.prompt([
|
|
151
|
+
{
|
|
152
|
+
type: 'input',
|
|
153
|
+
name: 'outputPath',
|
|
154
|
+
message: i18next.t('imgMerge.outputPathPrompt'),
|
|
155
|
+
default: defaultPath,
|
|
156
|
+
validate: (input) => input.trim() ? true : i18next.t('imgMerge.outputPathRequired')
|
|
157
|
+
}
|
|
158
|
+
]);
|
|
159
|
+
return outputPath.trim();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function validateImageFiles(files) {
|
|
163
|
+
if (!files || files.length < 2) {
|
|
164
|
+
return i18next.t('imgMerge.needAtLeastTwo');
|
|
165
|
+
}
|
|
166
|
+
for (const filePath of files) {
|
|
167
|
+
if (!fs.existsSync(filePath)) {
|
|
168
|
+
return `${i18next.t('imgMerge.notExist')}: ${filePath}`;
|
|
169
|
+
}
|
|
170
|
+
if (!fs.statSync(filePath).isFile()) {
|
|
171
|
+
return `${i18next.t('imgMerge.notFile')}: ${filePath}`;
|
|
172
|
+
}
|
|
173
|
+
if (!isImageFile(filePath)) {
|
|
174
|
+
return `${i18next.t('imgMerge.notImage')}: ${filePath}`;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function mergeImages(files, direction, outputPath) {
|
|
181
|
+
const metas = await Promise.all(files.map(async (filePath) => {
|
|
182
|
+
const image = sharp(filePath).rotate();
|
|
183
|
+
const metadata = await image.metadata();
|
|
184
|
+
const buffer = await image.toBuffer();
|
|
185
|
+
return { filePath, metadata, buffer };
|
|
186
|
+
}));
|
|
187
|
+
|
|
188
|
+
const widths = metas.map((m) => m.metadata.width || 0);
|
|
189
|
+
const heights = metas.map((m) => m.metadata.height || 0);
|
|
190
|
+
if (widths.some((w) => w <= 0) || heights.some((h) => h <= 0)) {
|
|
191
|
+
throw new Error(i18next.t('imgMerge.invalidImageSize'));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const isHorizontal = direction === 'horizontal';
|
|
195
|
+
const canvasWidth = isHorizontal ? widths.reduce((sum, w) => sum + w, 0) : Math.max(...widths);
|
|
196
|
+
const canvasHeight = isHorizontal ? Math.max(...heights) : heights.reduce((sum, h) => sum + h, 0);
|
|
197
|
+
|
|
198
|
+
let offsetX = 0;
|
|
199
|
+
let offsetY = 0;
|
|
200
|
+
const composite = metas.map((meta, index) => {
|
|
201
|
+
const item = {
|
|
202
|
+
input: meta.buffer,
|
|
203
|
+
left: offsetX,
|
|
204
|
+
top: offsetY
|
|
205
|
+
};
|
|
206
|
+
if (isHorizontal) {
|
|
207
|
+
offsetX += widths[index];
|
|
208
|
+
} else {
|
|
209
|
+
offsetY += heights[index];
|
|
210
|
+
}
|
|
211
|
+
return item;
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
215
|
+
|
|
216
|
+
await sharp({
|
|
217
|
+
create: {
|
|
218
|
+
width: canvasWidth,
|
|
219
|
+
height: canvasHeight,
|
|
220
|
+
channels: 4,
|
|
221
|
+
background: { r: 255, g: 255, b: 255, alpha: 1 }
|
|
222
|
+
}
|
|
223
|
+
})
|
|
224
|
+
.composite(composite)
|
|
225
|
+
.png()
|
|
226
|
+
.toFile(outputPath);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export async function imgMergeHandler() {
|
|
230
|
+
const sourceType = await selectFromMenu(
|
|
231
|
+
i18next.t('imgMerge.selectSource'),
|
|
232
|
+
[
|
|
233
|
+
{ name: i18next.t('imgMerge.sourceFolder'), value: 'folder' },
|
|
234
|
+
{ name: i18next.t('imgMerge.sourceMulti'), value: 'multi' }
|
|
235
|
+
],
|
|
236
|
+
true,
|
|
237
|
+
i18next.t('common.back')
|
|
238
|
+
);
|
|
239
|
+
if (sourceType === '__BACK__') return;
|
|
240
|
+
|
|
241
|
+
let files = [];
|
|
242
|
+
let baseDir = process.cwd();
|
|
243
|
+
if (sourceType === 'folder') {
|
|
244
|
+
const folderPath = await pickFolderPath();
|
|
245
|
+
if (!folderPath) return;
|
|
246
|
+
baseDir = folderPath;
|
|
247
|
+
files = listImagesInDir(folderPath);
|
|
248
|
+
} else {
|
|
249
|
+
const selectedFiles = await pickMultiImageFiles();
|
|
250
|
+
if (!selectedFiles) return;
|
|
251
|
+
files = selectedFiles;
|
|
252
|
+
if (files.length > 0) {
|
|
253
|
+
baseDir = path.dirname(files[0]);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const validationResult = validateImageFiles(files);
|
|
258
|
+
if (validationResult !== true) {
|
|
259
|
+
console.log(validationResult);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const direction = await selectFromMenu(
|
|
264
|
+
i18next.t('imgMerge.selectDirection'),
|
|
265
|
+
[
|
|
266
|
+
{ name: i18next.t('imgMerge.horizontal'), value: 'horizontal' },
|
|
267
|
+
{ name: i18next.t('imgMerge.vertical'), value: 'vertical' }
|
|
268
|
+
],
|
|
269
|
+
true,
|
|
270
|
+
i18next.t('common.back')
|
|
271
|
+
);
|
|
272
|
+
if (direction === '__BACK__') return;
|
|
273
|
+
|
|
274
|
+
const outputPath = await pickOutputPath(baseDir);
|
|
275
|
+
if (!outputPath) return;
|
|
276
|
+
|
|
277
|
+
try {
|
|
278
|
+
await mergeImages(files, direction, outputPath);
|
|
279
|
+
console.log(i18next.t('imgMerge.success', { outputPath }));
|
|
280
|
+
await copy(outputPath);
|
|
281
|
+
console.log(i18next.t('imgMerge.copied', { outputPath }));
|
|
282
|
+
} catch (error) {
|
|
283
|
+
console.error(i18next.t('imgMerge.error', { message: error.message }));
|
|
284
|
+
}
|
|
285
|
+
}
|
package/src/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import { unicodeHandler } from './commands/unicode.js';
|
|
|
11
11
|
import { imgBase64Handler } from './commands/imgBase64.js';
|
|
12
12
|
import { imgConvertHandler } from './commands/imgConvert.js';
|
|
13
13
|
import { imgSplitHandler } from './commands/imgSplit.js';
|
|
14
|
+
import { imgMergeHandler } from './commands/imgMerge.js';
|
|
14
15
|
import { timeFormatHandler } from './commands/timeFormat.js';
|
|
15
16
|
import { timeCalcHandler } from './commands/timeCalc.js';
|
|
16
17
|
import { mockHandler } from './commands/mock.js';
|
|
@@ -58,6 +59,7 @@ function getFeatures() {
|
|
|
58
59
|
{ name: i18next.t('menu.features.imgBase64'), value: 'imgBase64' },
|
|
59
60
|
{ name: i18next.t('menu.features.imgConvert'), value: 'imgConvert' },
|
|
60
61
|
{ name: i18next.t('menu.features.imgSplit'), value: 'imgSplit' },
|
|
62
|
+
{ name: i18next.t('menu.features.imgMerge'), value: 'imgMerge' },
|
|
61
63
|
{ name: i18next.t('menu.features.dominantColor'), value: 'dominantColor' },
|
|
62
64
|
{ name: i18next.t('menu.features.colorPick'), value: 'colorPick' },
|
|
63
65
|
{ name: i18next.t('menu.features.crosshair'), value: 'crosshair' },
|
|
@@ -131,6 +133,46 @@ function getFeatureIndex(key) {
|
|
|
131
133
|
return -1;
|
|
132
134
|
}
|
|
133
135
|
|
|
136
|
+
function findFeaturesByKeyword(features, keyword) {
|
|
137
|
+
const normalizedKeyword = keyword.trim().toLowerCase();
|
|
138
|
+
if (!normalizedKeyword) return [];
|
|
139
|
+
return features.filter((feature) => {
|
|
140
|
+
const name = feature.name.toLowerCase();
|
|
141
|
+
const value = feature.value.toLowerCase();
|
|
142
|
+
return name.includes(normalizedKeyword) || value.includes(normalizedKeyword);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function selectFeatureFromSearchResults(matchedFeatures) {
|
|
147
|
+
console.log(`\n${i18next.t('menu.searchResultTitle')}`);
|
|
148
|
+
matchedFeatures.forEach((feature, index) => {
|
|
149
|
+
console.log(`${index + 1}. ${feature.name}`);
|
|
150
|
+
});
|
|
151
|
+
console.log(`0. ${i18next.t('common.back')}`);
|
|
152
|
+
console.log('');
|
|
153
|
+
|
|
154
|
+
const { selected } = await inquirer.prompt([
|
|
155
|
+
{
|
|
156
|
+
type: 'input',
|
|
157
|
+
name: 'selected',
|
|
158
|
+
message: i18next.t('menu.searchPrompt'),
|
|
159
|
+
validate: (input) => {
|
|
160
|
+
if (input === '0') return true;
|
|
161
|
+
const index = parseInt(input);
|
|
162
|
+
if (index >= 1 && index <= matchedFeatures.length) {
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
return i18next.t('menu.invalid');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
]);
|
|
169
|
+
|
|
170
|
+
if (selected === '0') {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
return matchedFeatures[parseInt(selected) - 1];
|
|
174
|
+
}
|
|
175
|
+
|
|
134
176
|
async function showMenu() {
|
|
135
177
|
const features = getFeatures();
|
|
136
178
|
|
|
@@ -176,7 +218,10 @@ async function showMenu() {
|
|
|
176
218
|
if (index >= 0 && index < features.length) {
|
|
177
219
|
return true;
|
|
178
220
|
}
|
|
179
|
-
|
|
221
|
+
if (findFeaturesByKeyword(features, input).length > 0) {
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
return i18next.t('menu.invalidWithSearch');
|
|
180
225
|
}
|
|
181
226
|
}
|
|
182
227
|
]);
|
|
@@ -187,10 +232,27 @@ async function showMenu() {
|
|
|
187
232
|
}
|
|
188
233
|
|
|
189
234
|
const index = getFeatureIndex(choice);
|
|
190
|
-
|
|
235
|
+
let selectedFeature = null;
|
|
236
|
+
if (index >= 0 && index < features.length) {
|
|
237
|
+
selectedFeature = features[index];
|
|
238
|
+
} else {
|
|
239
|
+
const matchedFeatures = findFeaturesByKeyword(features, choice);
|
|
240
|
+
if (matchedFeatures.length === 0) {
|
|
241
|
+
console.log(i18next.t('menu.searchNoMatch', { keyword: choice }));
|
|
242
|
+
return showMenu();
|
|
243
|
+
}
|
|
244
|
+
if (matchedFeatures.length === 1) {
|
|
245
|
+
selectedFeature = matchedFeatures[0];
|
|
246
|
+
console.log(i18next.t('menu.searchAutoSelect', { feature: selectedFeature.name }));
|
|
247
|
+
} else {
|
|
248
|
+
selectedFeature = await selectFeatureFromSearchResults(matchedFeatures);
|
|
249
|
+
if (!selectedFeature) {
|
|
250
|
+
return showMenu();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
191
254
|
|
|
192
255
|
try {
|
|
193
|
-
// console.log('Handling action:', selectedFeature.value);
|
|
194
256
|
await handleAction(selectedFeature.value);
|
|
195
257
|
} catch (error) {
|
|
196
258
|
console.error('Error:', error.message);
|
|
@@ -222,6 +284,9 @@ async function handleAction(action) {
|
|
|
222
284
|
case 'imgSplit':
|
|
223
285
|
await imgSplitHandler();
|
|
224
286
|
break;
|
|
287
|
+
case 'imgMerge':
|
|
288
|
+
await imgMergeHandler();
|
|
289
|
+
break;
|
|
225
290
|
case 'dominantColor':
|
|
226
291
|
await dominantColorHandler();
|
|
227
292
|
break;
|
package/src/locales/en.js
CHANGED
|
@@ -29,13 +29,19 @@ export default {
|
|
|
29
29
|
menu: {
|
|
30
30
|
title: 'xw-devtool-cli Menu',
|
|
31
31
|
exit: 'Exit',
|
|
32
|
-
prompt: 'Please enter feature key',
|
|
32
|
+
prompt: 'Please enter feature key or search keyword',
|
|
33
|
+
searchPrompt: 'Enter number to select result',
|
|
34
|
+
searchResultTitle: 'Search Results',
|
|
35
|
+
invalidWithSearch: 'Invalid input. Please enter a valid menu key or searchable keyword.',
|
|
36
|
+
searchNoMatch: 'No feature matches "{{keyword}}". Please try again.',
|
|
37
|
+
searchAutoSelect: 'Matched feature: {{feature}}',
|
|
33
38
|
invalid: 'Invalid selection. Please enter a valid menu key.',
|
|
34
39
|
bye: 'Bye!',
|
|
35
40
|
features: {
|
|
36
41
|
imgBase64: 'Image <-> Base64',
|
|
37
42
|
imgConvert: 'Image Format Convert',
|
|
38
43
|
imgSplit: 'Image Splitter',
|
|
44
|
+
imgMerge: 'Image Merger',
|
|
39
45
|
dominantColor: 'Image Dominant Color',
|
|
40
46
|
colorPick: 'Color Picker',
|
|
41
47
|
crosshair: 'Screen Crosshair',
|
|
@@ -151,6 +157,42 @@ export default {
|
|
|
151
157
|
vLines: 'Vertical split lines (e.g. 10 20 30 or 10% 20%):',
|
|
152
158
|
success: 'Successfully split image into {{count}} parts. Saved to {{path}}'
|
|
153
159
|
},
|
|
160
|
+
imgMerge: {
|
|
161
|
+
selectSource: 'Select image source',
|
|
162
|
+
sourceFolder: 'Use images from a folder',
|
|
163
|
+
sourceMulti: 'Select multiple images',
|
|
164
|
+
inputMethod: 'Select input method',
|
|
165
|
+
folderDialog: 'Select folder (dialog)',
|
|
166
|
+
folderManual: 'Enter folder path manually',
|
|
167
|
+
multiDialog: 'Select multiple images (dialog)',
|
|
168
|
+
multiManual: 'Enter multiple image paths manually',
|
|
169
|
+
continueAdd: 'Continue adding images?',
|
|
170
|
+
yes: 'Yes',
|
|
171
|
+
no: 'No',
|
|
172
|
+
enterFolderPath: 'Enter image folder path:',
|
|
173
|
+
enterImagePaths: 'Enter image paths (comma or newline separated):',
|
|
174
|
+
notExist: 'Path does not exist',
|
|
175
|
+
notDirectory: 'The path is not a folder',
|
|
176
|
+
notFile: 'The path is not a file',
|
|
177
|
+
notImage: 'The file is not a supported image format',
|
|
178
|
+
needAtLeastTwo: 'At least 2 images are required for merging.',
|
|
179
|
+
dialogFail: 'Dialog is unavailable or canceled. Please retry or use manual input.',
|
|
180
|
+
selectDirection: 'Select merge direction',
|
|
181
|
+
horizontal: 'Horizontal merge (left to right)',
|
|
182
|
+
vertical: 'Vertical merge (top to bottom)',
|
|
183
|
+
selectOutput: 'Select output mode',
|
|
184
|
+
outputDefault: 'Default output to input directory',
|
|
185
|
+
outputFolderDialog: 'Select output directory (dialog)',
|
|
186
|
+
outputFolderManual: 'Enter output directory manually',
|
|
187
|
+
outputFileManual: 'Enter full output file path manually',
|
|
188
|
+
enterOutputDir: 'Enter output directory path:',
|
|
189
|
+
outputPathPrompt: 'Enter output file path:',
|
|
190
|
+
outputPathRequired: 'Output file path cannot be empty.',
|
|
191
|
+
invalidImageSize: 'One or more images have invalid dimensions.',
|
|
192
|
+
success: 'Merge completed. Output saved to: {{outputPath}}',
|
|
193
|
+
copied: 'Output path copied to clipboard: {{outputPath}}',
|
|
194
|
+
error: 'Merge failed: {{message}}'
|
|
195
|
+
},
|
|
154
196
|
imgCompress: {
|
|
155
197
|
selectSource: 'Select input method',
|
|
156
198
|
sourceFileDialog: 'Select file (dialog)',
|
package/src/locales/zh.js
CHANGED
|
@@ -29,13 +29,19 @@ export default {
|
|
|
29
29
|
menu: {
|
|
30
30
|
title: 'xw-devtool-cli 菜单',
|
|
31
31
|
exit: '退出',
|
|
32
|
-
prompt: '
|
|
32
|
+
prompt: '请输入功能键或搜索关键字',
|
|
33
|
+
searchPrompt: '请输入序号选择结果',
|
|
34
|
+
searchResultTitle: '搜索结果',
|
|
35
|
+
invalidWithSearch: '无效输入,请输入有效菜单键或可匹配的搜索关键字。',
|
|
36
|
+
searchNoMatch: '未找到与“{{keyword}}”匹配的功能,请重试。',
|
|
37
|
+
searchAutoSelect: '已匹配到功能:{{feature}}',
|
|
33
38
|
invalid: '无效选择,请输入有效的菜单键。',
|
|
34
39
|
bye: '再见!',
|
|
35
40
|
features: {
|
|
36
41
|
imgBase64: '图片 <-> Base64',
|
|
37
42
|
imgConvert: '图片格式转换',
|
|
38
43
|
imgSplit: '图片分割工具',
|
|
44
|
+
imgMerge: '图片拼接工具',
|
|
39
45
|
dominantColor: '图片主色识别',
|
|
40
46
|
colorPick: '颜色吸取',
|
|
41
47
|
crosshair: '全屏十字辅助线',
|
|
@@ -151,6 +157,42 @@ export default {
|
|
|
151
157
|
vLines: '垂直分割线 (例如 10 20 30 或 10% 20%):',
|
|
152
158
|
success: '成功将图片分割为 {{count}} 份。保存至 {{path}}'
|
|
153
159
|
},
|
|
160
|
+
imgMerge: {
|
|
161
|
+
selectSource: '选择图片来源',
|
|
162
|
+
sourceFolder: '选择目录中的图片',
|
|
163
|
+
sourceMulti: '选择多张图片',
|
|
164
|
+
inputMethod: '选择输入方式',
|
|
165
|
+
folderDialog: '选择文件夹(对话框)',
|
|
166
|
+
folderManual: '手动输入文件夹路径',
|
|
167
|
+
multiDialog: '多选图片(对话框)',
|
|
168
|
+
multiManual: '手动输入多张图片路径',
|
|
169
|
+
continueAdd: '是否继续添加图片',
|
|
170
|
+
yes: '是',
|
|
171
|
+
no: '否',
|
|
172
|
+
enterFolderPath: '请输入图片目录路径:',
|
|
173
|
+
enterImagePaths: '请输入图片路径(可用逗号或换行分隔):',
|
|
174
|
+
notExist: '路径不存在',
|
|
175
|
+
notDirectory: '输入路径不是文件夹',
|
|
176
|
+
notFile: '输入路径不是文件',
|
|
177
|
+
notImage: '文件不是支持的图片格式',
|
|
178
|
+
needAtLeastTwo: '至少需要 2 张图片才能拼接。',
|
|
179
|
+
dialogFail: '对话框不可用或已取消,请重试或改用手动输入。',
|
|
180
|
+
selectDirection: '选择拼接方向',
|
|
181
|
+
horizontal: '水平拼接(从左到右)',
|
|
182
|
+
vertical: '垂直拼接(从上到下)',
|
|
183
|
+
selectOutput: '选择输出方式',
|
|
184
|
+
outputDefault: '默认输出到输入目录',
|
|
185
|
+
outputFolderDialog: '选择输出目录(对话框)',
|
|
186
|
+
outputFolderManual: '手动输入输出目录',
|
|
187
|
+
outputFileManual: '手动输入完整输出文件路径',
|
|
188
|
+
enterOutputDir: '请输入输出目录路径:',
|
|
189
|
+
outputPathPrompt: '请输入输出文件路径:',
|
|
190
|
+
outputPathRequired: '输出文件路径不能为空。',
|
|
191
|
+
invalidImageSize: '存在无法读取尺寸的图片文件。',
|
|
192
|
+
success: '拼接完成,已输出到:{{outputPath}}',
|
|
193
|
+
copied: '输出路径已复制到剪贴板:{{outputPath}}',
|
|
194
|
+
error: '拼接失败:{{message}}'
|
|
195
|
+
},
|
|
154
196
|
imgCompress: {
|
|
155
197
|
selectSource: '选择输入方式',
|
|
156
198
|
sourceFileDialog: '选择文件(对话框)',
|
package/src/utils/fileDialog.js
CHANGED
|
@@ -16,6 +16,21 @@ export function selectFile(filter = 'All Files|*.*') {
|
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
export function selectFiles(filter = 'All Files|*.*') {
|
|
20
|
+
if (!isWindows()) return null;
|
|
21
|
+
try {
|
|
22
|
+
const cmd = `powershell -NoProfile -Command "Add-Type -AssemblyName System.Windows.Forms; $fd = New-Object System.Windows.Forms.OpenFileDialog; $fd.Filter='${filter.replace(/"/g, '\\"')}'; $fd.Multiselect=$true; if($fd.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK){ $json = ConvertTo-Json @($fd.FileNames) -Compress; $bytes = [System.Text.Encoding]::UTF8.GetBytes($json); Write-Output ([Convert]::ToBase64String($bytes)) }"`;
|
|
23
|
+
const buf = execSync(cmd, { stdio: ['pipe', 'pipe', 'ignore'] });
|
|
24
|
+
const output = Buffer.isBuffer(buf) ? buf.toString('utf8').trim() : String(buf).trim();
|
|
25
|
+
if (!output) return null;
|
|
26
|
+
const parsed = JSON.parse(Buffer.from(output, 'base64').toString('utf8'));
|
|
27
|
+
if (!Array.isArray(parsed)) return null;
|
|
28
|
+
return parsed;
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
19
34
|
export function saveFile(defaultName = 'output.txt', filter = 'All Files|*.*') {
|
|
20
35
|
if (!isWindows()) return null;
|
|
21
36
|
try {
|