@veltra/sheet 2.5.4 → 2.5.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/sheet/insert-image.js +45 -10
- package/dist/components/sheet/popups/insert-image-popup.js +28 -7
- package/dist/components/sheet/sheet-context-menu.js +55 -6
- package/dist/components/sheet/style.d.ts +1 -0
- package/dist/components/sheet/style.js +1 -0
- package/package.json +5 -5
- package/src/components/sheet/__test__/insert-image-popup.test.ts +50 -0
- package/src/components/sheet/__test__/insert-image.test.ts +54 -0
- package/src/components/sheet/__test__/sheet-context-menu.test.ts +91 -4
- package/src/components/sheet/insert-image.ts +38 -10
- package/src/components/sheet/popups/insert-image-popup.vue +24 -3
- package/src/components/sheet/sheet-context-menu.ts +52 -5
- package/src/components/sheet/style.ts +2 -1
|
@@ -25,6 +25,18 @@ function resolveImageType(file) {
|
|
|
25
25
|
const ext = file.name.includes(".") ? file.name.slice(file.name.lastIndexOf(".") + 1) : "";
|
|
26
26
|
return EXT_TO_TYPE[ext.toLowerCase()];
|
|
27
27
|
}
|
|
28
|
+
/** 取当前活动格锚点;无选区时 message.error 提示并返回 undefined */
|
|
29
|
+
function activeCellAnchor(ctx) {
|
|
30
|
+
const active = ctx.getSelection().activeCell;
|
|
31
|
+
if (!active) {
|
|
32
|
+
message.error("请先选择单元格");
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
return { from: {
|
|
36
|
+
row: active.row,
|
|
37
|
+
col: active.col
|
|
38
|
+
} };
|
|
39
|
+
}
|
|
28
40
|
/**
|
|
29
41
|
* 从本地 File 插入浮动图片:读字节 → 映射类型 → 锚定活动格 → ctx.insertImage。
|
|
30
42
|
* 不支持的格式 / 无活动格时 message.error 提示并返回 undefined。
|
|
@@ -35,19 +47,42 @@ async function insertImageFromFile(ctx, file) {
|
|
|
35
47
|
message.error("不支持的图片格式,请选择 png / jpeg / gif / svg / webp");
|
|
36
48
|
return;
|
|
37
49
|
}
|
|
38
|
-
const
|
|
39
|
-
if (!
|
|
40
|
-
message.error("请先选择单元格");
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
50
|
+
const anchor = activeCellAnchor(ctx);
|
|
51
|
+
if (!anchor) return void 0;
|
|
43
52
|
const buffer = await file.arrayBuffer();
|
|
44
53
|
return ctx.insertImage({
|
|
45
54
|
data: new Uint8Array(buffer),
|
|
46
55
|
type,
|
|
47
|
-
anchor
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
56
|
+
anchor
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* 从 URL 插入浮动图片:URL 直存 src(渲染层直接引用),data 为空字节、
|
|
61
|
+
* type 仅作提示(取 URL 路径扩展名,未知按 png)。锚定活动格,经 ctx.insertImage 写入。
|
|
62
|
+
* 空串 / 非法 URL / 无活动格时 message.error 提示并返回 undefined。
|
|
63
|
+
*/
|
|
64
|
+
function insertImageFromUrl(ctx, url) {
|
|
65
|
+
const src = url.trim();
|
|
66
|
+
if (!src) {
|
|
67
|
+
message.error("请输入图片 URL");
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
let pathname;
|
|
71
|
+
try {
|
|
72
|
+
pathname = new URL(src).pathname;
|
|
73
|
+
} catch {
|
|
74
|
+
message.error("请输入有效的图片 URL");
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const anchor = activeCellAnchor(ctx);
|
|
78
|
+
if (!anchor) return void 0;
|
|
79
|
+
const ext = pathname.includes(".") ? pathname.slice(pathname.lastIndexOf(".") + 1) : "";
|
|
80
|
+
const type = EXT_TO_TYPE[ext.toLowerCase()] ?? "png";
|
|
81
|
+
return ctx.insertImage({
|
|
82
|
+
data: /* @__PURE__ */ new Uint8Array(),
|
|
83
|
+
type,
|
|
84
|
+
src,
|
|
85
|
+
anchor
|
|
51
86
|
});
|
|
52
87
|
}
|
|
53
88
|
/**
|
|
@@ -76,4 +111,4 @@ function pickAndInsertImage(ctx) {
|
|
|
76
111
|
input.click();
|
|
77
112
|
}
|
|
78
113
|
//#endregion
|
|
79
|
-
export { INSERT_IMAGE_ACCEPT, insertImageFromFile, pickAndInsertImage, resolveImageType };
|
|
114
|
+
export { INSERT_IMAGE_ACCEPT, insertImageFromFile, insertImageFromUrl, pickAndInsertImage, resolveImageType };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { INSERT_IMAGE_ACCEPT, insertImageFromFile } from "../insert-image.js";
|
|
2
|
-
import {
|
|
3
|
-
import { UFilePicker } from "@veltra/desktop";
|
|
1
|
+
import { INSERT_IMAGE_ACCEPT, insertImageFromFile, insertImageFromUrl } from "../insert-image.js";
|
|
2
|
+
import { Fragment, createElementBlock, createElementVNode, createTextVNode, createVNode, defineComponent, normalizeClass, openBlock, ref, unref, withCtx, withKeys } from "vue";
|
|
3
|
+
import { UButton, UFilePicker, UInput } from "@veltra/desktop";
|
|
4
4
|
import { bem } from "@veltra/utils";
|
|
5
5
|
//#region src/components/sheet/popups/insert-image-popup.vue
|
|
6
6
|
const _sfc_main = /*@__PURE__*/ defineComponent({
|
|
@@ -10,27 +10,48 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
|
|
|
10
10
|
emits: ["close"],
|
|
11
11
|
setup(__props, { emit: __emit }) {
|
|
12
12
|
/**
|
|
13
|
-
*
|
|
13
|
+
* 插入图片面板:UFilePicker 选本地文件,或输入图片 URL 插入;均走共享
|
|
14
|
+
* insertImageFromFile / insertImageFromUrl,锚定当前选区活动格。
|
|
14
15
|
* 不参与面板事务(单次插入 = 一个 undo 单元)。
|
|
15
16
|
*/
|
|
16
17
|
const props = __props;
|
|
17
18
|
const emit = __emit;
|
|
18
19
|
const cls = bem("sheet");
|
|
20
|
+
const imageUrl = ref("");
|
|
19
21
|
function handlePick(files) {
|
|
20
22
|
const file = files[0];
|
|
21
23
|
if (!file) return;
|
|
22
24
|
emit("close");
|
|
23
25
|
insertImageFromFile(props.context, file);
|
|
24
26
|
}
|
|
27
|
+
function handleUrlInsert() {
|
|
28
|
+
if (!imageUrl.value.trim()) return;
|
|
29
|
+
emit("close");
|
|
30
|
+
insertImageFromUrl(props.context, imageUrl.value);
|
|
31
|
+
}
|
|
25
32
|
return (_ctx, _cache) => {
|
|
26
|
-
return openBlock(),
|
|
33
|
+
return openBlock(), createElementBlock(Fragment, null, [createVNode(unref(UFilePicker), {
|
|
27
34
|
accept: unref(INSERT_IMAGE_ACCEPT),
|
|
28
35
|
class: normalizeClass(unref(cls).e("insert-image-picker")),
|
|
29
36
|
onPick: handlePick
|
|
30
37
|
}, {
|
|
31
|
-
default: withCtx(() => [createElementVNode("div", { class: normalizeClass(unref(cls).e("insert-image-hint")) }, [_cache[
|
|
38
|
+
default: withCtx(() => [createElementVNode("div", { class: normalizeClass(unref(cls).e("insert-image-hint")) }, [_cache[1] || (_cache[1] = createTextVNode(" 选择图片文件 ", -1)), createElementVNode("div", { class: normalizeClass(unref(cls).e("insert-image-sub")) }, "支持 png / jpeg / gif / svg / webp", 2)], 2)]),
|
|
39
|
+
_: 1
|
|
40
|
+
}, 8, ["accept", "class"]), createElementVNode("div", { class: normalizeClass(unref(cls).e("popup-row")) }, [createVNode(unref(UInput), {
|
|
41
|
+
modelValue: imageUrl.value,
|
|
42
|
+
"onUpdate:modelValue": _cache[0] || (_cache[0] = ($event) => imageUrl.value = $event),
|
|
43
|
+
placeholder: "输入图片 URL",
|
|
44
|
+
size: "small",
|
|
45
|
+
onKeydown: withKeys(handleUrlInsert, ["enter"])
|
|
46
|
+
}, null, 8, ["modelValue"]), createVNode(unref(UButton), {
|
|
47
|
+
size: "small",
|
|
48
|
+
type: "primary",
|
|
49
|
+
disabled: !imageUrl.value.trim(),
|
|
50
|
+
onClick: handleUrlInsert
|
|
51
|
+
}, {
|
|
52
|
+
default: withCtx(() => [..._cache[2] || (_cache[2] = [createTextVNode(" 插入 ", -1)])]),
|
|
32
53
|
_: 1
|
|
33
|
-
}, 8, ["
|
|
54
|
+
}, 8, ["disabled"])], 2)], 64);
|
|
34
55
|
};
|
|
35
56
|
}
|
|
36
57
|
});
|
|
@@ -48,21 +48,29 @@ function resolveRenderSize(propsRows, propsCols, sheetRows, sheetCols) {
|
|
|
48
48
|
cols: sheetCols > 0 ? sheetCols : propsCols ?? 26
|
|
49
49
|
};
|
|
50
50
|
}
|
|
51
|
-
|
|
51
|
+
/** 菜单内嵌数量输入项(插入行列数 / 小数位数共用) */
|
|
52
|
+
function menuNumberRender(options) {
|
|
52
53
|
return defineComponent({
|
|
53
|
-
name: "
|
|
54
|
+
name: "SheetMenuNumberRender",
|
|
54
55
|
setup() {
|
|
55
56
|
return () => h(_sfc_main, {
|
|
56
57
|
prefix: options.prefix,
|
|
57
58
|
suffix: options.suffix,
|
|
58
59
|
defaultValue: options.defaultValue,
|
|
59
|
-
min:
|
|
60
|
-
max:
|
|
60
|
+
min: options.min,
|
|
61
|
+
max: options.max,
|
|
61
62
|
onConfirm: options.onConfirm
|
|
62
63
|
});
|
|
63
64
|
}
|
|
64
65
|
});
|
|
65
66
|
}
|
|
67
|
+
function insertCountRender(options) {
|
|
68
|
+
return menuNumberRender({
|
|
69
|
+
...options,
|
|
70
|
+
min: 1,
|
|
71
|
+
max: INSERT_COUNT_MAX
|
|
72
|
+
});
|
|
73
|
+
}
|
|
66
74
|
function primaryRange(ctx) {
|
|
67
75
|
return ctx.getSelection().ranges[0] ?? null;
|
|
68
76
|
}
|
|
@@ -158,7 +166,14 @@ function buildColHeaderMenus(ctx) {
|
|
|
158
166
|
}
|
|
159
167
|
];
|
|
160
168
|
}
|
|
161
|
-
/**
|
|
169
|
+
/**
|
|
170
|
+
* 对当前选区应用 numFmt(经 applyStyle → 命令系统,天然可 undo/redo)。
|
|
171
|
+
* 仅影响显示,单元格恒存原始值(见 sheet-core `core/format.ts`)。
|
|
172
|
+
*/
|
|
173
|
+
function applyNumFmtToSelection(ctx, numFmt) {
|
|
174
|
+
for (const range of ctx.getSelection().ranges) ctx.applyStyle(range, { numFmt });
|
|
175
|
+
}
|
|
176
|
+
/** body 格右键菜单:合并/取消合并 + 设置数据格式 + 插入图片(行列插入/删除仅行号/列头菜单) */
|
|
162
177
|
function buildBodyMenus(ctx) {
|
|
163
178
|
const mergeTool = defaultToolRegistry.get("merge");
|
|
164
179
|
const unmergeTool = defaultToolRegistry.get("unmerge");
|
|
@@ -174,6 +189,40 @@ function buildBodyMenus(ctx) {
|
|
|
174
189
|
disabled: unmergeTool?.disabled?.(ctx) ?? true,
|
|
175
190
|
callback: () => unmergeTool?.onClick(ctx)
|
|
176
191
|
},
|
|
192
|
+
{ divider: true },
|
|
193
|
+
{
|
|
194
|
+
label: "设置数据格式",
|
|
195
|
+
disabled: !active,
|
|
196
|
+
children: [
|
|
197
|
+
{
|
|
198
|
+
label: "日期",
|
|
199
|
+
callback: () => applyNumFmtToSelection(ctx, { type: "date" })
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
label: "千分位金额",
|
|
203
|
+
callback: () => applyNumFmtToSelection(ctx, { type: "thousands" })
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
label: "大写金额",
|
|
207
|
+
callback: () => applyNumFmtToSelection(ctx, { type: "cnUpper" })
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
label: "小数位数",
|
|
211
|
+
keepOpen: true,
|
|
212
|
+
render: menuNumberRender({
|
|
213
|
+
prefix: "保留",
|
|
214
|
+
suffix: "位小数",
|
|
215
|
+
defaultValue: 2,
|
|
216
|
+
min: 0,
|
|
217
|
+
max: 10,
|
|
218
|
+
onConfirm: (n) => applyNumFmtToSelection(ctx, {
|
|
219
|
+
type: "fixed",
|
|
220
|
+
digits: n
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
]
|
|
225
|
+
},
|
|
177
226
|
{
|
|
178
227
|
label: "插入图片",
|
|
179
228
|
disabled: !active,
|
|
@@ -200,4 +249,4 @@ function ensureContextMenuSelection(ctx, info, renderSize) {
|
|
|
200
249
|
}
|
|
201
250
|
}
|
|
202
251
|
//#endregion
|
|
203
|
-
export { INSERT_COUNT_MAX, buildBodyMenus, buildColHeaderMenus, buildRowHeaderMenus, defaultInsertCount, ensureContextMenuSelection, isColInSelection, isRowInSelection, resolveRenderSize, wholeColRange, wholeRowRange };
|
|
252
|
+
export { INSERT_COUNT_MAX, applyNumFmtToSelection, buildBodyMenus, buildColHeaderMenus, buildRowHeaderMenus, defaultInsertCount, ensureContextMenuSelection, isColInSelection, isRowInSelection, resolveRenderSize, wholeColRange, wholeRowRange };
|
|
@@ -8,4 +8,5 @@ import "@veltra/desktop/components/loading/style";
|
|
|
8
8
|
import "@veltra/desktop/components/message/style";
|
|
9
9
|
import "@veltra/desktop/components/input/style";
|
|
10
10
|
import "@veltra/desktop/components/file-picker/style";
|
|
11
|
+
import "@veltra/desktop/components/button/style";
|
|
11
12
|
import "@veltra/desktop/components/icon/style";
|
|
@@ -9,4 +9,5 @@ import "@veltra/desktop/components/loading/style";
|
|
|
9
9
|
import "@veltra/desktop/components/message/style";
|
|
10
10
|
import "@veltra/desktop/components/input/style";
|
|
11
11
|
import "@veltra/desktop/components/file-picker/style";
|
|
12
|
+
import "@veltra/desktop/components/button/style";
|
|
12
13
|
import "@veltra/desktop/components/icon/style";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@veltra/sheet",
|
|
3
|
-
"version": "2.5.
|
|
3
|
+
"version": "2.5.5",
|
|
4
4
|
"homepage": "https://github.com/cabinet-fe/ultra-ui#readme",
|
|
5
5
|
"bugs": {
|
|
6
6
|
"url": "https://github.com/cabinet-fe/ultra-ui/issues"
|
|
@@ -36,11 +36,11 @@
|
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"@cat-kit/core": ">=1.2.1",
|
|
38
38
|
"@cat-kit/fe": ">=1.2.1",
|
|
39
|
-
"@veltra/desktop": "^1.7.
|
|
39
|
+
"@veltra/desktop": "^1.7.9",
|
|
40
40
|
"@veltra/icons": "^1.5.0",
|
|
41
|
-
"@veltra/sheet-core": "^2.5.
|
|
42
|
-
"@veltra/styles": "^1.7.
|
|
43
|
-
"@veltra/utils": "^1.7.
|
|
41
|
+
"@veltra/sheet-core": "^2.5.5",
|
|
42
|
+
"@veltra/styles": "^1.7.9",
|
|
43
|
+
"@veltra/utils": "^1.7.9",
|
|
44
44
|
"vue": ">=3.5.42"
|
|
45
45
|
}
|
|
46
46
|
}
|
|
@@ -39,6 +39,36 @@ vi.mock('@veltra/desktop', async () => {
|
|
|
39
39
|
'pick'
|
|
40
40
|
)
|
|
41
41
|
}
|
|
42
|
+
}),
|
|
43
|
+
UInput: defineComponent({
|
|
44
|
+
name: 'UInputStub',
|
|
45
|
+
props: ['modelValue'],
|
|
46
|
+
emits: ['update:modelValue'],
|
|
47
|
+
setup(props, { emit }) {
|
|
48
|
+
return () =>
|
|
49
|
+
h('input', {
|
|
50
|
+
class: 'url-input-stub',
|
|
51
|
+
value: props.modelValue as string,
|
|
52
|
+
onInput: (e: Event) => emit('update:modelValue', (e.target as HTMLInputElement).value)
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
}),
|
|
56
|
+
UButton: defineComponent({
|
|
57
|
+
name: 'UButtonStub',
|
|
58
|
+
props: ['disabled'],
|
|
59
|
+
emits: ['click'],
|
|
60
|
+
setup(props, { emit, slots }) {
|
|
61
|
+
return () =>
|
|
62
|
+
h(
|
|
63
|
+
'button',
|
|
64
|
+
{
|
|
65
|
+
class: 'url-btn-stub',
|
|
66
|
+
disabled: props.disabled as boolean,
|
|
67
|
+
onClick: () => emit('click')
|
|
68
|
+
},
|
|
69
|
+
slots.default?.()
|
|
70
|
+
)
|
|
71
|
+
}
|
|
42
72
|
})
|
|
43
73
|
}
|
|
44
74
|
})
|
|
@@ -86,4 +116,24 @@ describe('USheetInsertImagePopup', () => {
|
|
|
86
116
|
expect(sheet.getImages()[0]?.type).toBe('png')
|
|
87
117
|
expect([...sheet.getImages()[0]!.data]).toEqual([9, 8, 7])
|
|
88
118
|
})
|
|
119
|
+
|
|
120
|
+
it('输入 URL 点插入 → 关闭弹层并插入 src 来源图片', async () => {
|
|
121
|
+
const closed = vi.fn()
|
|
122
|
+
const { el, sheet } = mountPopup(closed)
|
|
123
|
+
const input = el.querySelector<HTMLInputElement>('.url-input-stub')!
|
|
124
|
+
const btn = el.querySelector<HTMLButtonElement>('.url-btn-stub')!
|
|
125
|
+
expect(btn.disabled).toBe(true)
|
|
126
|
+
|
|
127
|
+
input.value = 'https://example.com/pic.gif'
|
|
128
|
+
input.dispatchEvent(new Event('input'))
|
|
129
|
+
await Promise.resolve()
|
|
130
|
+
expect(btn.disabled).toBe(false)
|
|
131
|
+
|
|
132
|
+
btn.click()
|
|
133
|
+
expect(closed).toHaveBeenCalledTimes(1)
|
|
134
|
+
expect(sheet.getImages()).toHaveLength(1)
|
|
135
|
+
expect(sheet.getImages()[0]?.src).toBe('https://example.com/pic.gif')
|
|
136
|
+
expect(sheet.getImages()[0]?.type).toBe('gif')
|
|
137
|
+
expect(sheet.getImages()[0]?.anchor.from).toEqual({ row: 1, col: 1 })
|
|
138
|
+
})
|
|
89
139
|
})
|
|
@@ -5,6 +5,7 @@ import { createSheetContext } from '../../../tools/context'
|
|
|
5
5
|
import {
|
|
6
6
|
INSERT_IMAGE_ACCEPT,
|
|
7
7
|
insertImageFromFile,
|
|
8
|
+
insertImageFromUrl,
|
|
8
9
|
pickAndInsertImage,
|
|
9
10
|
resolveImageType
|
|
10
11
|
} from '../insert-image'
|
|
@@ -84,6 +85,59 @@ describe('insertImageFromFile', () => {
|
|
|
84
85
|
})
|
|
85
86
|
})
|
|
86
87
|
|
|
88
|
+
describe('insertImageFromUrl', () => {
|
|
89
|
+
it('合法 URL → 锚定活动格并经 ctx.insertImage 写入 src 来源图片', () => {
|
|
90
|
+
const sheet = new Sheet()
|
|
91
|
+
const ctx = createSheetContext(sheet)
|
|
92
|
+
ctx.selectCell({ row: 2, col: 3 })
|
|
93
|
+
const id = insertImageFromUrl(ctx, 'https://example.com/assets/logo.webp')
|
|
94
|
+
expect(id).toBeTruthy()
|
|
95
|
+
const images = ctx.getImages()
|
|
96
|
+
expect(images).toHaveLength(1)
|
|
97
|
+
expect(images[0]?.src).toBe('https://example.com/assets/logo.webp')
|
|
98
|
+
expect(images[0]?.type).toBe('webp')
|
|
99
|
+
expect(images[0]?.data).toHaveLength(0)
|
|
100
|
+
expect(images[0]?.anchor.from).toEqual({ row: 2, col: 3 })
|
|
101
|
+
expect(mocks.message.error).not.toHaveBeenCalled()
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('URL 无已知扩展名 → type 按 png 提示,仍可插入', () => {
|
|
105
|
+
const sheet = new Sheet()
|
|
106
|
+
const ctx = createSheetContext(sheet)
|
|
107
|
+
ctx.selectCell({ row: 1, col: 1 })
|
|
108
|
+
const id = insertImageFromUrl(ctx, 'https://example.com/image?id=1')
|
|
109
|
+
expect(id).toBeTruthy()
|
|
110
|
+
expect(ctx.getImages()[0]?.type).toBe('png')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('空串 → message.error,不写入', () => {
|
|
114
|
+
const sheet = new Sheet()
|
|
115
|
+
const ctx = createSheetContext(sheet)
|
|
116
|
+
ctx.selectCell({ row: 1, col: 1 })
|
|
117
|
+
expect(insertImageFromUrl(ctx, ' ')).toBeUndefined()
|
|
118
|
+
expect(ctx.getImages()).toHaveLength(0)
|
|
119
|
+
expect(mocks.message.error).toHaveBeenCalledWith('请输入图片 URL')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('非法 URL → message.error,不写入', () => {
|
|
123
|
+
const sheet = new Sheet()
|
|
124
|
+
const ctx = createSheetContext(sheet)
|
|
125
|
+
ctx.selectCell({ row: 1, col: 1 })
|
|
126
|
+
expect(insertImageFromUrl(ctx, 'not a url')).toBeUndefined()
|
|
127
|
+
expect(ctx.getImages()).toHaveLength(0)
|
|
128
|
+
expect(mocks.message.error).toHaveBeenCalledWith('请输入有效的图片 URL')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('无活动格 → message.error,不写入', () => {
|
|
132
|
+
const sheet = new Sheet()
|
|
133
|
+
const ctx = createSheetContext(sheet)
|
|
134
|
+
sheet.selection.clear()
|
|
135
|
+
expect(insertImageFromUrl(ctx, 'https://example.com/a.png')).toBeUndefined()
|
|
136
|
+
expect(ctx.getImages()).toHaveLength(0)
|
|
137
|
+
expect(mocks.message.error).toHaveBeenCalledWith('请先选择单元格')
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
87
141
|
describe('pickAndInsertImage', () => {
|
|
88
142
|
it('创建隐藏 file input(同一 accept)并 click', () => {
|
|
89
143
|
const sheet = new Sheet()
|
|
@@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'
|
|
|
4
4
|
|
|
5
5
|
import { createSheetContext } from '../../../tools/context'
|
|
6
6
|
import {
|
|
7
|
+
applyNumFmtToSelection,
|
|
7
8
|
buildBodyMenus,
|
|
8
9
|
buildColHeaderMenus,
|
|
9
10
|
buildRowHeaderMenus,
|
|
@@ -147,13 +148,19 @@ describe('build*Menus', () => {
|
|
|
147
148
|
])
|
|
148
149
|
})
|
|
149
150
|
|
|
150
|
-
it('body
|
|
151
|
+
it('body 菜单:合并/取消合并 + 设置数据格式子菜单 + 插入图片,不含行列插入删除', () => {
|
|
151
152
|
const sheet = new Sheet()
|
|
152
153
|
const ctx = createSheetContext(sheet)
|
|
153
154
|
sheet.selectCell({ row: 0, col: 0 })
|
|
154
155
|
const menus = buildBodyMenus(ctx)
|
|
155
|
-
expect(menus).toHaveLength(
|
|
156
|
-
expect(menus.map((m) => m.label
|
|
156
|
+
expect(menus).toHaveLength(5)
|
|
157
|
+
expect(menus.map((m) => m.label ?? (m.divider ? '---' : ''))).toEqual([
|
|
158
|
+
'合并单元格',
|
|
159
|
+
'取消合并单元格',
|
|
160
|
+
'---',
|
|
161
|
+
'设置数据格式',
|
|
162
|
+
'插入图片'
|
|
163
|
+
])
|
|
157
164
|
const insertImage = menus.find((m) => m.label === '插入图片')!
|
|
158
165
|
expect(insertImage.disabled).toBeFalsy()
|
|
159
166
|
expect(insertImage.callback).toBeTruthy()
|
|
@@ -164,12 +171,92 @@ describe('build*Menus', () => {
|
|
|
164
171
|
expect(labels).not.toContain('删除列')
|
|
165
172
|
})
|
|
166
173
|
|
|
167
|
-
it('body
|
|
174
|
+
it('body 设置数据格式子菜单:日期 / 千分位金额 / 大写金额 / 小数位数', () => {
|
|
175
|
+
const sheet = new Sheet()
|
|
176
|
+
const ctx = createSheetContext(sheet)
|
|
177
|
+
sheet.selectCell({ row: 0, col: 0 })
|
|
178
|
+
const sub = buildBodyMenus(ctx).find((m) => m.label === '设置数据格式')!
|
|
179
|
+
expect(sub.disabled).toBeFalsy()
|
|
180
|
+
const children = sub.children!
|
|
181
|
+
expect(children.map((c) => c.label)).toEqual(['日期', '千分位金额', '大写金额', '小数位数'])
|
|
182
|
+
// 小数位数项内嵌位数输入(keepOpen + render),其余三项直点直用
|
|
183
|
+
expect(children[3]?.keepOpen).toBe(true)
|
|
184
|
+
expect(children[3]?.render).toBeTruthy()
|
|
185
|
+
expect(children.slice(0, 3).every((c) => typeof c.callback === 'function')).toBe(true)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('设置数据格式:四项各应用 numFmt 到当前选区全部单元格,原始值不变', () => {
|
|
189
|
+
const sheet = new Sheet()
|
|
190
|
+
const ctx = createSheetContext(sheet)
|
|
191
|
+
sheet.setCellValue({ row: 0, col: 0 }, 45000)
|
|
192
|
+
sheet.setCellValue({ row: 0, col: 1 }, 1234567.89)
|
|
193
|
+
const range = createRange({ row: 0, col: 0 }, { row: 0, col: 1 })
|
|
194
|
+
|
|
195
|
+
const cases = [
|
|
196
|
+
{ label: '日期', numFmt: { type: 'date' } },
|
|
197
|
+
{ label: '千分位金额', numFmt: { type: 'thousands' } },
|
|
198
|
+
{ label: '大写金额', numFmt: { type: 'cnUpper' } }
|
|
199
|
+
] as const
|
|
200
|
+
for (const c of cases) {
|
|
201
|
+
sheet.selectRange(range)
|
|
202
|
+
const sub = buildBodyMenus(ctx).find((m) => m.label === '设置数据格式')!
|
|
203
|
+
sub.children!.find((child) => child.label === c.label)!.callback!()
|
|
204
|
+
for (const addr of [
|
|
205
|
+
{ row: 0, col: 0 },
|
|
206
|
+
{ row: 0, col: 1 }
|
|
207
|
+
]) {
|
|
208
|
+
expect(ctx.getCellStyle(addr)?.numFmt).toEqual(c.numFmt)
|
|
209
|
+
}
|
|
210
|
+
// 仅样式变化,原始值不变
|
|
211
|
+
expect(ctx.getCellData({ row: 0, col: 0 })?.v).toBe(45000)
|
|
212
|
+
expect(ctx.getCellData({ row: 0, col: 1 })?.v).toBe(1234567.89)
|
|
213
|
+
sheet.undo()
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// 小数位数:经同一入口 applyNumFmtToSelection(render onConfirm 同路径)
|
|
217
|
+
sheet.selectRange(range)
|
|
218
|
+
applyNumFmtToSelection(ctx, { type: 'fixed', digits: 2 })
|
|
219
|
+
expect(ctx.getCellStyle({ row: 0, col: 0 })?.numFmt).toEqual({ type: 'fixed', digits: 2 })
|
|
220
|
+
expect(ctx.getCellStyle({ row: 0, col: 1 })?.numFmt).toEqual({ type: 'fixed', digits: 2 })
|
|
221
|
+
sheet.undo()
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('设置数据格式:undo/redo 恢复样式', () => {
|
|
225
|
+
const sheet = new Sheet()
|
|
226
|
+
const ctx = createSheetContext(sheet)
|
|
227
|
+
sheet.setCellValue({ row: 1, col: 1 }, 1.005)
|
|
228
|
+
sheet.selectRange(createRange({ row: 1, col: 1 }, { row: 1, col: 1 }))
|
|
229
|
+
const sub = buildBodyMenus(ctx).find((m) => m.label === '设置数据格式')!
|
|
230
|
+
sub.children!.find((c) => c.label === '千分位金额')!.callback!()
|
|
231
|
+
expect(ctx.getCellStyle({ row: 1, col: 1 })?.numFmt).toEqual({ type: 'thousands' })
|
|
232
|
+
|
|
233
|
+
expect(ctx.undo()).toBe(true)
|
|
234
|
+
expect(ctx.getCellStyle({ row: 1, col: 1 })?.numFmt).toBeUndefined()
|
|
235
|
+
expect(ctx.redo()).toBe(true)
|
|
236
|
+
expect(ctx.getCellStyle({ row: 1, col: 1 })?.numFmt).toEqual({ type: 'thousands' })
|
|
237
|
+
expect(ctx.getCellData({ row: 1, col: 1 })?.v).toBe(1.005)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('设置数据格式:清空选区时为无操作(不产生 undo 单元)', () => {
|
|
241
|
+
const sheet = new Sheet()
|
|
242
|
+
const ctx = createSheetContext(sheet)
|
|
243
|
+
sheet.setCellValue({ row: 0, col: 0 }, 1)
|
|
244
|
+
sheet.selection.clear()
|
|
245
|
+
applyNumFmtToSelection(ctx, { type: 'date' })
|
|
246
|
+
expect(ctx.getCellStyle({ row: 0, col: 0 })).toBeUndefined()
|
|
247
|
+
// undo 直接回滚到 setCellValue 之前,证明未新增 undo 单元
|
|
248
|
+
expect(ctx.undo()).toBe(true)
|
|
249
|
+
expect(ctx.getCellData({ row: 0, col: 0 })).toBeUndefined()
|
|
250
|
+
expect(ctx.canUndo).toBe(false)
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('body 插入图片 / 设置数据格式:无活动格时禁用', () => {
|
|
168
254
|
const sheet = new Sheet()
|
|
169
255
|
const ctx = createSheetContext(sheet)
|
|
170
256
|
sheet.selection.clear()
|
|
171
257
|
const menus = buildBodyMenus(ctx)
|
|
172
258
|
expect(menus.find((m) => m.label === '插入图片')?.disabled).toBe(true)
|
|
259
|
+
expect(menus.find((m) => m.label === '设置数据格式')?.disabled).toBe(true)
|
|
173
260
|
})
|
|
174
261
|
|
|
175
262
|
it('行号/列头菜单仍含插入删除行列', () => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { message } from '@veltra/desktop'
|
|
2
|
-
import type { SheetImageType } from '@veltra/sheet-core/core/image'
|
|
2
|
+
import type { SheetImageAnchor, SheetImageType } from '@veltra/sheet-core/core/image'
|
|
3
3
|
|
|
4
4
|
import type { SheetContext } from '../../tools/context'
|
|
5
5
|
|
|
@@ -32,6 +32,16 @@ export function resolveImageType(file: File): SheetImageType | undefined {
|
|
|
32
32
|
return EXT_TO_TYPE[ext.toLowerCase()]
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/** 取当前活动格锚点;无选区时 message.error 提示并返回 undefined */
|
|
36
|
+
function activeCellAnchor(ctx: SheetContext): SheetImageAnchor | undefined {
|
|
37
|
+
const active = ctx.getSelection().activeCell
|
|
38
|
+
if (!active) {
|
|
39
|
+
message.error('请先选择单元格')
|
|
40
|
+
return undefined
|
|
41
|
+
}
|
|
42
|
+
return { from: { row: active.row, col: active.col } }
|
|
43
|
+
}
|
|
44
|
+
|
|
35
45
|
/**
|
|
36
46
|
* 从本地 File 插入浮动图片:读字节 → 映射类型 → 锚定活动格 → ctx.insertImage。
|
|
37
47
|
* 不支持的格式 / 无活动格时 message.error 提示并返回 undefined。
|
|
@@ -45,17 +55,35 @@ export async function insertImageFromFile(
|
|
|
45
55
|
message.error('不支持的图片格式,请选择 png / jpeg / gif / svg / webp')
|
|
46
56
|
return undefined
|
|
47
57
|
}
|
|
48
|
-
const
|
|
49
|
-
if (!
|
|
50
|
-
|
|
58
|
+
const anchor = activeCellAnchor(ctx)
|
|
59
|
+
if (!anchor) return undefined
|
|
60
|
+
const buffer = await file.arrayBuffer()
|
|
61
|
+
return ctx.insertImage({ data: new Uint8Array(buffer), type, anchor })
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 从 URL 插入浮动图片:URL 直存 src(渲染层直接引用),data 为空字节、
|
|
66
|
+
* type 仅作提示(取 URL 路径扩展名,未知按 png)。锚定活动格,经 ctx.insertImage 写入。
|
|
67
|
+
* 空串 / 非法 URL / 无活动格时 message.error 提示并返回 undefined。
|
|
68
|
+
*/
|
|
69
|
+
export function insertImageFromUrl(ctx: SheetContext, url: string): string | undefined {
|
|
70
|
+
const src = url.trim()
|
|
71
|
+
if (!src) {
|
|
72
|
+
message.error('请输入图片 URL')
|
|
51
73
|
return undefined
|
|
52
74
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
75
|
+
let pathname: string
|
|
76
|
+
try {
|
|
77
|
+
pathname = new URL(src).pathname
|
|
78
|
+
} catch {
|
|
79
|
+
message.error('请输入有效的图片 URL')
|
|
80
|
+
return undefined
|
|
81
|
+
}
|
|
82
|
+
const anchor = activeCellAnchor(ctx)
|
|
83
|
+
if (!anchor) return undefined
|
|
84
|
+
const ext = pathname.includes('.') ? pathname.slice(pathname.lastIndexOf('.') + 1) : ''
|
|
85
|
+
const type = EXT_TO_TYPE[ext.toLowerCase()] ?? 'png'
|
|
86
|
+
return ctx.insertImage({ data: new Uint8Array(), type, src, anchor })
|
|
59
87
|
}
|
|
60
88
|
|
|
61
89
|
/**
|
|
@@ -9,19 +9,32 @@
|
|
|
9
9
|
<div :class="cls.e('insert-image-sub')">支持 png / jpeg / gif / svg / webp</div>
|
|
10
10
|
</div>
|
|
11
11
|
</u-file-picker>
|
|
12
|
+
<div :class="cls.e('popup-row')">
|
|
13
|
+
<u-input
|
|
14
|
+
v-model="imageUrl"
|
|
15
|
+
placeholder="输入图片 URL"
|
|
16
|
+
size="small"
|
|
17
|
+
@keydown.enter="handleUrlInsert"
|
|
18
|
+
/>
|
|
19
|
+
<u-button size="small" type="primary" :disabled="!imageUrl.trim()" @click="handleUrlInsert">
|
|
20
|
+
插入
|
|
21
|
+
</u-button>
|
|
22
|
+
</div>
|
|
12
23
|
</template>
|
|
13
24
|
|
|
14
25
|
<script lang="ts" setup>
|
|
15
|
-
import { UFilePicker } from '@veltra/desktop'
|
|
26
|
+
import { UButton, UFilePicker, UInput } from '@veltra/desktop'
|
|
16
27
|
import { bem } from '@veltra/utils'
|
|
28
|
+
import { ref } from 'vue'
|
|
17
29
|
|
|
18
30
|
import type { SheetContext } from '../../../tools/context'
|
|
19
|
-
import { INSERT_IMAGE_ACCEPT, insertImageFromFile } from '../insert-image'
|
|
31
|
+
import { INSERT_IMAGE_ACCEPT, insertImageFromFile, insertImageFromUrl } from '../insert-image'
|
|
20
32
|
|
|
21
33
|
defineOptions({ name: 'USheetInsertImagePopup' })
|
|
22
34
|
|
|
23
35
|
/**
|
|
24
|
-
*
|
|
36
|
+
* 插入图片面板:UFilePicker 选本地文件,或输入图片 URL 插入;均走共享
|
|
37
|
+
* insertImageFromFile / insertImageFromUrl,锚定当前选区活动格。
|
|
25
38
|
* 不参与面板事务(单次插入 = 一个 undo 单元)。
|
|
26
39
|
*/
|
|
27
40
|
const props = defineProps<{ context: SheetContext }>()
|
|
@@ -30,10 +43,18 @@ const emit = defineEmits<{ close: [] }>()
|
|
|
30
43
|
|
|
31
44
|
const cls = bem('sheet')
|
|
32
45
|
|
|
46
|
+
const imageUrl = ref('')
|
|
47
|
+
|
|
33
48
|
function handlePick(files: File[]): void {
|
|
34
49
|
const file = files[0]
|
|
35
50
|
if (!file) return
|
|
36
51
|
emit('close')
|
|
37
52
|
void insertImageFromFile(props.context, file)
|
|
38
53
|
}
|
|
54
|
+
|
|
55
|
+
function handleUrlInsert(): void {
|
|
56
|
+
if (!imageUrl.value.trim()) return
|
|
57
|
+
emit('close')
|
|
58
|
+
insertImageFromUrl(props.context, imageUrl.value)
|
|
59
|
+
}
|
|
39
60
|
</script>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ContextmenuItem } from '@veltra/desktop'
|
|
2
2
|
import { createRange, type CellAddress, type CellRange } from '@veltra/sheet-core/core/address'
|
|
3
|
+
import type { NumFmt } from '@veltra/sheet-core/core/style/types'
|
|
3
4
|
import { defineComponent, h } from 'vue'
|
|
4
5
|
|
|
5
6
|
import type { SheetContext } from '../../tools/context'
|
|
@@ -11,6 +12,10 @@ import { pickAndInsertImage } from './insert-image'
|
|
|
11
12
|
export const INSERT_COUNT_MIN = 1
|
|
12
13
|
export const INSERT_COUNT_MAX = 1000
|
|
13
14
|
|
|
15
|
+
/** 小数位数钳制范围 */
|
|
16
|
+
export const DECIMAL_PLACES_MIN = 0
|
|
17
|
+
export const DECIMAL_PLACES_MAX = 10
|
|
18
|
+
|
|
14
19
|
/** 选区覆盖行/列数 → 插入默认 N(min 1 / max 1000) */
|
|
15
20
|
export function defaultInsertCount(range: CellRange | null, axis: 'row' | 'col'): number {
|
|
16
21
|
if (!range) return INSERT_COUNT_MIN
|
|
@@ -55,28 +60,40 @@ export function resolveRenderSize(
|
|
|
55
60
|
}
|
|
56
61
|
}
|
|
57
62
|
|
|
58
|
-
|
|
63
|
+
/** 菜单内嵌数量输入项(插入行列数 / 小数位数共用) */
|
|
64
|
+
function menuNumberRender(options: {
|
|
59
65
|
prefix: string
|
|
60
66
|
suffix: string
|
|
61
67
|
defaultValue: number
|
|
68
|
+
min: number
|
|
69
|
+
max: number
|
|
62
70
|
onConfirm: (n: number) => void
|
|
63
71
|
}): ContextmenuItem['render'] {
|
|
64
72
|
return defineComponent({
|
|
65
|
-
name: '
|
|
73
|
+
name: 'SheetMenuNumberRender',
|
|
66
74
|
setup() {
|
|
67
75
|
return () =>
|
|
68
76
|
h(InsertCountMenuItem, {
|
|
69
77
|
prefix: options.prefix,
|
|
70
78
|
suffix: options.suffix,
|
|
71
79
|
defaultValue: options.defaultValue,
|
|
72
|
-
min:
|
|
73
|
-
max:
|
|
80
|
+
min: options.min,
|
|
81
|
+
max: options.max,
|
|
74
82
|
onConfirm: options.onConfirm
|
|
75
83
|
})
|
|
76
84
|
}
|
|
77
85
|
})
|
|
78
86
|
}
|
|
79
87
|
|
|
88
|
+
function insertCountRender(options: {
|
|
89
|
+
prefix: string
|
|
90
|
+
suffix: string
|
|
91
|
+
defaultValue: number
|
|
92
|
+
onConfirm: (n: number) => void
|
|
93
|
+
}): ContextmenuItem['render'] {
|
|
94
|
+
return menuNumberRender({ ...options, min: INSERT_COUNT_MIN, max: INSERT_COUNT_MAX })
|
|
95
|
+
}
|
|
96
|
+
|
|
80
97
|
function primaryRange(ctx: SheetContext): CellRange | null {
|
|
81
98
|
return ctx.getSelection().ranges[0] ?? null
|
|
82
99
|
}
|
|
@@ -171,7 +188,15 @@ export function buildColHeaderMenus(ctx: SheetContext): ContextmenuItem[] {
|
|
|
171
188
|
]
|
|
172
189
|
}
|
|
173
190
|
|
|
174
|
-
/**
|
|
191
|
+
/**
|
|
192
|
+
* 对当前选区应用 numFmt(经 applyStyle → 命令系统,天然可 undo/redo)。
|
|
193
|
+
* 仅影响显示,单元格恒存原始值(见 sheet-core `core/format.ts`)。
|
|
194
|
+
*/
|
|
195
|
+
export function applyNumFmtToSelection(ctx: SheetContext, numFmt: NumFmt): void {
|
|
196
|
+
for (const range of ctx.getSelection().ranges) ctx.applyStyle(range, { numFmt })
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** body 格右键菜单:合并/取消合并 + 设置数据格式 + 插入图片(行列插入/删除仅行号/列头菜单) */
|
|
175
200
|
export function buildBodyMenus(ctx: SheetContext): ContextmenuItem[] {
|
|
176
201
|
const mergeTool = defaultToolRegistry.get('merge')
|
|
177
202
|
const unmergeTool = defaultToolRegistry.get('unmerge')
|
|
@@ -188,6 +213,28 @@ export function buildBodyMenus(ctx: SheetContext): ContextmenuItem[] {
|
|
|
188
213
|
disabled: unmergeTool?.disabled?.(ctx) ?? true,
|
|
189
214
|
callback: () => unmergeTool?.onClick(ctx)
|
|
190
215
|
},
|
|
216
|
+
{ divider: true },
|
|
217
|
+
{
|
|
218
|
+
label: '设置数据格式',
|
|
219
|
+
disabled: !active,
|
|
220
|
+
children: [
|
|
221
|
+
{ label: '日期', callback: () => applyNumFmtToSelection(ctx, { type: 'date' }) },
|
|
222
|
+
{ label: '千分位金额', callback: () => applyNumFmtToSelection(ctx, { type: 'thousands' }) },
|
|
223
|
+
{ label: '大写金额', callback: () => applyNumFmtToSelection(ctx, { type: 'cnUpper' }) },
|
|
224
|
+
{
|
|
225
|
+
label: '小数位数',
|
|
226
|
+
keepOpen: true,
|
|
227
|
+
render: menuNumberRender({
|
|
228
|
+
prefix: '保留',
|
|
229
|
+
suffix: '位小数',
|
|
230
|
+
defaultValue: 2,
|
|
231
|
+
min: DECIMAL_PLACES_MIN,
|
|
232
|
+
max: DECIMAL_PLACES_MAX,
|
|
233
|
+
onConfirm: (n) => applyNumFmtToSelection(ctx, { type: 'fixed', digits: n })
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
]
|
|
237
|
+
},
|
|
191
238
|
{ label: '插入图片', disabled: !active, callback: () => pickAndInsertImage(ctx) }
|
|
192
239
|
]
|
|
193
240
|
}
|
|
@@ -7,7 +7,8 @@ import '@veltra/desktop/components/scroll/style'
|
|
|
7
7
|
import '@veltra/desktop/components/tip/style'
|
|
8
8
|
import '@veltra/desktop/components/loading/style' // v-loading(sheet.vue grid 容器)
|
|
9
9
|
import '@veltra/desktop/components/message/style' // message / messageConfirm(弹层、tabs、公式栏)
|
|
10
|
-
import '@veltra/desktop/components/input/style' // UInput(find-popup)
|
|
10
|
+
import '@veltra/desktop/components/input/style' // UInput(find-popup / insert-image-popup)
|
|
11
11
|
import '@veltra/desktop/components/file-picker/style' // UFilePicker(insert-image-popup)
|
|
12
|
+
import '@veltra/desktop/components/button/style' // UButton(insert-image-popup URL 插入)
|
|
12
13
|
import '@veltra/desktop/components/icon/style' // UIcon(sheet-toolbar / sheet-tabs)
|
|
13
14
|
import './style.scss'
|