vome-core 0.0.98 → 0.0.100

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 (37) hide show
  1. package/dist/admin/config/plugin-dev.js +1 -1
  2. package/dist/admin/crud/components/vm-color-picker.vue +1074 -61
  3. package/dist/admin/crud/config.js +1 -1
  4. package/dist/admin/crud/confirm.js +1 -1
  5. package/dist/admin/crud/dict.js +1 -1
  6. package/dist/admin/crud/index.js +1 -1
  7. package/dist/admin/crud/key.js +1 -1
  8. package/dist/admin/crud/mitt.js +1 -1
  9. package/dist/admin/crud/plugins.js +1 -1
  10. package/dist/admin/crud/span.js +1 -1
  11. package/dist/admin/crud/style.js +1 -1
  12. package/dist/admin/crud/validate.js +1 -1
  13. package/dist/admin/directives/perm.js +1 -1
  14. package/dist/admin/hooks/useUpload.js +1 -1
  15. package/dist/admin/lib/browser.js +1 -1
  16. package/dist/admin/lib/cn.js +1 -1
  17. package/dist/admin/lib/dialog-float.js +1 -1
  18. package/dist/admin/lib/export-excel.js +1 -1
  19. package/dist/admin/lib/import-excel.js +1 -1
  20. package/dist/admin/lib/json.js +1 -1
  21. package/dist/admin/lib/menu.js +1 -1
  22. package/dist/admin/lib/tree.js +1 -1
  23. package/dist/admin/lib/upload.js +1 -1
  24. package/dist/admin/lib/video-frame.js +1 -1
  25. package/dist/agent/index.js +0 -23
  26. package/dist/index.js +1 -1
  27. package/dist/server/index.js +1 -1
  28. package/dist/shared/excel.js +1 -1
  29. package/dist/shared/index.js +1 -1
  30. package/dist/shared/locale-json.js +1 -1
  31. package/dist/shared/tree.js +1 -1
  32. package/dist/src/agent/index.d.ts +0 -1
  33. package/dist/src/agent/protocol.d.ts +1 -6
  34. package/dist/src/agent/types.d.ts +0 -28
  35. package/package.json +1 -1
  36. package/typings/admin/base/auth.d.ts +17 -20
  37. package/dist/src/agent/guards.d.ts +0 -3
@@ -1,34 +1,181 @@
1
1
  <template>
2
- <div class="vm-color-picker" :class="{ 'is-disabled': disabled }">
3
- <label class="vm-color-picker__swatch" :title="disabled ? undefined : '选择颜色'">
2
+ <div
3
+ ref="rootRef"
4
+ class="vm-color-picker"
5
+ :class="{ 'is-disabled': disabled, 'is-sm': size === 'sm', 'is-open': open }"
6
+ >
7
+ <button
8
+ ref="triggerRef"
9
+ type="button"
10
+ class="vm-color-picker__swatch"
11
+ :disabled="disabled"
12
+ :title="disabled ? undefined : '选择颜色'"
13
+ @click="toggle"
14
+ >
4
15
  <span
5
16
  class="vm-color-picker__swatch-fill"
6
17
  :style="{ background: cssColor }"
7
18
  />
8
- <input
9
- type="color"
10
- class="vm-color-picker__native"
11
- :value="pickerValue"
12
- :disabled="disabled"
13
- @input="onPicker"
14
- />
15
- </label>
19
+ </button>
20
+
16
21
  <input
17
22
  type="text"
18
23
  class="vm-color-picker__input"
19
- :value="display"
20
- :placeholder="placeholder"
24
+ :value="inlineHex"
25
+ :placeholder="placeholderText"
21
26
  :disabled="disabled"
22
27
  spellcheck="false"
23
28
  autocomplete="off"
24
- @input="onText"
25
- @blur="onBlur"
29
+ @input="onInlineInput"
30
+ @focus="openPanel"
31
+ @blur="onInlineBlur"
32
+ @keydown.esc.stop="closePanel"
26
33
  />
34
+
35
+ <Teleport to="body">
36
+ <FocusScope
37
+ v-if="open"
38
+ as-child
39
+ loop
40
+ :trapped="true"
41
+ @unmount-auto-focus="onPanelUnmountAutoFocus"
42
+ >
43
+ <div
44
+ ref="panelRef"
45
+ class="vm-color-picker__panel vm-dialog-float"
46
+ :style="panelStyle"
47
+ @pointerdown.stop
48
+ @mousedown.stop
49
+ >
50
+ <div
51
+ ref="svRef"
52
+ class="vm-color-picker__sv"
53
+ :style="{ background: svBg }"
54
+ @pointerdown="onSvPointer"
55
+ >
56
+ <span
57
+ class="vm-color-picker__sv-cursor"
58
+ :style="svCursorStyle"
59
+ />
60
+ </div>
61
+
62
+ <div class="vm-color-picker__sliders">
63
+ <button
64
+ v-if="hasEyeDropper"
65
+ type="button"
66
+ class="vm-color-picker__eyedropper"
67
+ title="吸取颜色"
68
+ @click="pickEyeDropper"
69
+ >
70
+ <!-- 内联 SVG:不依赖 remixicon 字体是否已加载 -->
71
+ <svg
72
+ class="vm-color-picker__eyedropper-ico"
73
+ viewBox="0 0 24 24"
74
+ width="16"
75
+ height="16"
76
+ aria-hidden="true"
77
+ >
78
+ <path
79
+ fill="currentColor"
80
+ d="M19.07 3.66a2.5 2.5 0 0 1 0 3.54l-1.42 1.41 2.12 2.12-1.41 1.42-2.12-2.13-7.07 7.07c-.3.3-.68.48-1.08.54l-3.36.48a.75.75 0 0 1-.84-.84l.48-3.36c.06-.4.24-.78.54-1.08l7.07-7.07-1.41-1.41 1.41-1.42 2.12 2.13 1.42-1.42a2.5 2.5 0 0 1 3.54 0Zm-2.12 2.12.71-.7a1 1 0 0 0-1.42-1.42l-.7.71 1.41 1.41ZM8.48 15.9l-.17.17-.28 1.9 1.9-.28.17-.17 5.66-5.66-1.62-1.62-5.66 5.66Z"
81
+ />
82
+ </svg>
83
+ </button>
84
+ <div class="vm-color-picker__slider-col">
85
+ <div
86
+ ref="hueRef"
87
+ class="vm-color-picker__hue"
88
+ @pointerdown="onHuePointer"
89
+ >
90
+ <span
91
+ class="vm-color-picker__slider-thumb"
92
+ :style="{ left: thumbLeft(hue / 360) }"
93
+ />
94
+ </div>
95
+ <div
96
+ ref="alphaRef"
97
+ class="vm-color-picker__alpha"
98
+ @pointerdown="onAlphaPointer"
99
+ >
100
+ <span
101
+ class="vm-color-picker__alpha-fill"
102
+ :style="{ background: alphaBg }"
103
+ />
104
+ <span
105
+ class="vm-color-picker__slider-thumb"
106
+ :style="{ left: thumbLeft(alpha) }"
107
+ />
108
+ </div>
109
+ </div>
110
+ </div>
111
+
112
+ <div class="vm-color-picker__footer">
113
+ <div class="vm-color-picker__format-wrap">
114
+ <button
115
+ type="button"
116
+ class="vm-color-picker__format"
117
+ :class="{ 'is-open': formatOpen }"
118
+ title="颜色格式"
119
+ @click.stop="toggleFormatMenu"
120
+ >
121
+ {{ formatLabel }}
122
+ <svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
123
+ <path fill="currentColor" d="M12 15.4 6.6 10l1.4-1.4 4 4 4-4L17.4 10 12 15.4Z" />
124
+ </svg>
125
+ </button>
126
+ <div v-if="formatOpen" class="vm-color-picker__format-menu" role="listbox">
127
+ <button
128
+ v-for="opt in FORMAT_OPTS"
129
+ :key="opt.id"
130
+ type="button"
131
+ class="vm-color-picker__format-item"
132
+ :class="{ 'is-active': colorFormat === opt.id }"
133
+ role="option"
134
+ :aria-selected="colorFormat === opt.id"
135
+ @click.stop="setFormat(opt.id)"
136
+ >
137
+ <span class="vm-color-picker__format-check" aria-hidden="true">
138
+ <svg v-if="colorFormat === opt.id" viewBox="0 0 24 24" width="14" height="14">
139
+ <path
140
+ fill="currentColor"
141
+ d="M9.55 17.6 4.9 12.95l1.4-1.4 3.25 3.25 7.15-7.15 1.4 1.4-8.55 8.55Z"
142
+ />
143
+ </svg>
144
+ </span>
145
+ {{ opt.label }}
146
+ </button>
147
+ </div>
148
+ </div>
149
+ <input
150
+ type="text"
151
+ class="vm-color-picker__value"
152
+ :value="panelValue"
153
+ spellcheck="false"
154
+ autocomplete="off"
155
+ @input="onPanelValueInput"
156
+ @blur="onPanelValueBlur"
157
+ @keydown.esc.stop="closePanel"
158
+ />
159
+ <input
160
+ type="text"
161
+ class="vm-color-picker__alpha-pct"
162
+ :value="alphaPctText"
163
+ spellcheck="false"
164
+ autocomplete="off"
165
+ @input="onAlphaPctInput"
166
+ @blur="onAlphaPctBlur"
167
+ @keydown.esc.stop="closePanel"
168
+ />
169
+ </div>
170
+ </div>
171
+ </FocusScope>
172
+ </Teleport>
27
173
  </div>
28
174
  </template>
29
175
 
30
176
  <script setup lang="ts">
31
- import { computed, ref, watch } from 'vue'
177
+ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
178
+ import { FocusScope } from 'reka-ui'
32
179
 
33
180
  defineOptions({ name: 'vm-color-picker' })
34
181
 
@@ -37,9 +184,11 @@ const props = withDefaults(
37
184
  modelValue?: string | null
38
185
  disabled?: boolean
39
186
  placeholder?: string
187
+ size?: 'default' | 'sm'
40
188
  }>(),
41
189
  {
42
190
  placeholder: '#000000',
191
+ size: 'default',
43
192
  },
44
193
  )
45
194
 
@@ -48,65 +197,665 @@ const emit = defineEmits<{
48
197
  change: [v: string]
49
198
  }>()
50
199
 
51
- const FALLBACK = '#000000'
200
+ type Rgba = { r: number; g: number; b: number; a: number }
201
+ type Hsv = { h: number; s: number; v: number }
202
+ type ColorFormat = 'hex' | 'rgb' | 'css' | 'hsl' | 'hsb'
52
203
 
53
- const display = ref(props.modelValue ?? '')
204
+ const FORMAT_OPTS: { id: ColorFormat; label: string }[] = [
205
+ { id: 'hex', label: 'Hex' },
206
+ { id: 'rgb', label: 'RGB' },
207
+ { id: 'css', label: 'CSS' },
208
+ { id: 'hsl', label: 'HSL' },
209
+ { id: 'hsb', label: 'HSB' },
210
+ ]
54
211
 
55
- watch(
56
- () => props.modelValue,
57
- (v) => {
58
- display.value = v ?? ''
59
- },
212
+ const THUMB_PX = 14
213
+
214
+ const rootRef = ref<HTMLElement | null>(null)
215
+ const triggerRef = ref<HTMLElement | null>(null)
216
+ const panelRef = ref<HTMLElement | null>(null)
217
+ const svRef = ref<HTMLElement | null>(null)
218
+ const hueRef = ref<HTMLElement | null>(null)
219
+ const alphaRef = ref<HTMLElement | null>(null)
220
+
221
+ const open = ref(false)
222
+ const suppressOpen = ref(false)
223
+ const panelStyle = ref<Record<string, string>>({})
224
+ const formatOpen = ref(false)
225
+ const colorFormat = ref<ColorFormat>('hex')
226
+
227
+ const hue = ref(0)
228
+ const sat = ref(0)
229
+ const val = ref(0)
230
+ const alpha = ref(1)
231
+
232
+ /** 行内 Hex(不含 #);面板按当前格式展示 */
233
+ const inlineHex = ref('')
234
+ const panelValue = ref('')
235
+ const alphaPctText = ref('100%')
236
+
237
+ const hasEyeDropper = ref(false)
238
+
239
+ const placeholderText = computed(() => {
240
+ const p = props.placeholder || '#000000'
241
+ return p.startsWith('#') ? p.slice(1) : p
242
+ })
243
+
244
+ const formatLabel = computed(
245
+ () => FORMAT_OPTS.find((o) => o.id === colorFormat.value)?.label ?? 'Hex',
60
246
  )
61
247
 
62
- /** 解析为 #rrggbb;无效返回 null */
63
- function parseHex(raw: string): string | null {
248
+ function clamp(n: number, min: number, max: number) {
249
+ return Math.min(max, Math.max(min, n))
250
+ }
251
+
252
+ /** 拇指中心落在轨道内,两端不溢出 */
253
+ function thumbLeft(ratio: number) {
254
+ const t = clamp(ratio, 0, 1)
255
+ return `calc(${t} * (100% - ${THUMB_PX}px) + ${THUMB_PX / 2}px)`
256
+ }
257
+
258
+ function toHex2(n: number) {
259
+ return clamp(Math.round(n), 0, 255).toString(16).padStart(2, '0')
260
+ }
261
+
262
+ function rgbToHex(r: number, g: number, b: number, a = 1): string {
263
+ const base = `#${toHex2(r)}${toHex2(g)}${toHex2(b)}`
264
+ if (a >= 1 - 1e-6) return base
265
+ return `${base}${toHex2(a * 255)}`
266
+ }
267
+
268
+ function hsvToRgb(h: number, s: number, v: number): { r: number; g: number; b: number } {
269
+ const hh = ((h % 360) + 360) % 360
270
+ const c = v * s
271
+ const x = c * (1 - Math.abs(((hh / 60) % 2) - 1))
272
+ const m = v - c
273
+ let rp = 0
274
+ let gp = 0
275
+ let bp = 0
276
+ if (hh < 60) {
277
+ rp = c
278
+ gp = x
279
+ } else if (hh < 120) {
280
+ rp = x
281
+ gp = c
282
+ } else if (hh < 180) {
283
+ gp = c
284
+ bp = x
285
+ } else if (hh < 240) {
286
+ gp = x
287
+ bp = c
288
+ } else if (hh < 300) {
289
+ rp = x
290
+ bp = c
291
+ } else {
292
+ rp = c
293
+ bp = x
294
+ }
295
+ return {
296
+ r: (rp + m) * 255,
297
+ g: (gp + m) * 255,
298
+ b: (bp + m) * 255,
299
+ }
300
+ }
301
+
302
+ function rgbToHsv(r: number, g: number, b: number): Hsv {
303
+ const rr = r / 255
304
+ const gg = g / 255
305
+ const bb = b / 255
306
+ const max = Math.max(rr, gg, bb)
307
+ const min = Math.min(rr, gg, bb)
308
+ const d = max - min
309
+ let h = 0
310
+ if (d !== 0) {
311
+ if (max === rr) h = ((gg - bb) / d) % 6
312
+ else if (max === gg) h = (bb - rr) / d + 2
313
+ else h = (rr - gg) / d + 4
314
+ h *= 60
315
+ if (h < 0) h += 360
316
+ }
317
+ const s = max === 0 ? 0 : d / max
318
+ return { h, s, v: max }
319
+ }
320
+
321
+ function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } {
322
+ const rr = r / 255
323
+ const gg = g / 255
324
+ const bb = b / 255
325
+ const max = Math.max(rr, gg, bb)
326
+ const min = Math.min(rr, gg, bb)
327
+ const l = (max + min) / 2
328
+ const d = max - min
329
+ let h = 0
330
+ let s = 0
331
+ if (d !== 0) {
332
+ s = d / (1 - Math.abs(2 * l - 1))
333
+ if (max === rr) h = ((gg - bb) / d) % 6
334
+ else if (max === gg) h = (bb - rr) / d + 2
335
+ else h = (rr - gg) / d + 4
336
+ h *= 60
337
+ if (h < 0) h += 360
338
+ }
339
+ return { h, s, l }
340
+ }
341
+
342
+ function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } {
343
+ const hh = ((h % 360) + 360) % 360
344
+ const c = (1 - Math.abs(2 * l - 1)) * s
345
+ const x = c * (1 - Math.abs(((hh / 60) % 2) - 1))
346
+ const m = l - c / 2
347
+ let rp = 0
348
+ let gp = 0
349
+ let bp = 0
350
+ if (hh < 60) {
351
+ rp = c
352
+ gp = x
353
+ } else if (hh < 120) {
354
+ rp = x
355
+ gp = c
356
+ } else if (hh < 180) {
357
+ gp = c
358
+ bp = x
359
+ } else if (hh < 240) {
360
+ gp = x
361
+ bp = c
362
+ } else if (hh < 300) {
363
+ rp = x
364
+ bp = c
365
+ } else {
366
+ rp = c
367
+ bp = x
368
+ }
369
+ return {
370
+ r: (rp + m) * 255,
371
+ g: (gp + m) * 255,
372
+ b: (bp + m) * 255,
373
+ }
374
+ }
375
+
376
+ /** 解析为 rgba;无效返回 null */
377
+ function parseColor(raw: string): Rgba | null {
378
+ const s = raw.trim()
379
+ if (!s) return null
380
+
381
+ if (s.startsWith('#')) {
382
+ let h = s.slice(1)
383
+ if (/^[0-9a-fA-F]{3}$/.test(h)) {
384
+ h = h
385
+ .split('')
386
+ .map((c) => c + c)
387
+ .join('')
388
+ }
389
+ if (/^[0-9a-fA-F]{6}$/.test(h)) {
390
+ return {
391
+ r: parseInt(h.slice(0, 2), 16),
392
+ g: parseInt(h.slice(2, 4), 16),
393
+ b: parseInt(h.slice(4, 6), 16),
394
+ a: 1,
395
+ }
396
+ }
397
+ if (/^[0-9a-fA-F]{8}$/.test(h)) {
398
+ return {
399
+ r: parseInt(h.slice(0, 2), 16),
400
+ g: parseInt(h.slice(2, 4), 16),
401
+ b: parseInt(h.slice(4, 6), 16),
402
+ a: parseInt(h.slice(6, 8), 16) / 255,
403
+ }
404
+ }
405
+ return null
406
+ }
407
+
408
+ if (/^[0-9a-fA-F]{3}$/.test(s) || /^[0-9a-fA-F]{6}$/.test(s) || /^[0-9a-fA-F]{8}$/.test(s)) {
409
+ return parseColor(`#${s}`)
410
+ }
411
+
412
+ const m = s.match(
413
+ /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/i,
414
+ )
415
+ if (m) {
416
+ return {
417
+ r: clamp(Number(m[1]), 0, 255),
418
+ g: clamp(Number(m[2]), 0, 255),
419
+ b: clamp(Number(m[3]), 0, 255),
420
+ a: m[4] != null ? clamp(Number(m[4]), 0, 1) : 1,
421
+ }
422
+ }
423
+
424
+ const hsl = s.match(
425
+ /^hsla?\(\s*([\d.]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%(?:\s*,\s*([\d.]+))?\s*\)$/i,
426
+ )
427
+ if (hsl) {
428
+ const rgb = hslToRgb(Number(hsl[1]), Number(hsl[2]) / 100, Number(hsl[3]) / 100)
429
+ return {
430
+ r: rgb.r,
431
+ g: rgb.g,
432
+ b: rgb.b,
433
+ a: hsl[4] != null ? clamp(Number(hsl[4]), 0, 1) : 1,
434
+ }
435
+ }
436
+
437
+ return null
438
+ }
439
+
440
+ /** 按当前格式解析面板中间输入(不改变 alpha,除非 CSS/rgba 自带) */
441
+ function parsePanelValue(raw: string, keepAlpha: number): Rgba | null {
64
442
  const s = raw.trim()
65
443
  if (!s) return null
66
- let h = s.startsWith('#') ? s.slice(1) : s
67
- if (/^[0-9a-fA-F]{3}$/.test(h)) {
68
- h = h
69
- .split('')
70
- .map((c) => c + c)
71
- .join('')
72
- }
73
- if (/^[0-9a-fA-F]{6}$/.test(h)) return `#${h.toLowerCase()}`
74
- if (/^[0-9a-fA-F]{8}$/.test(h)) return `#${h.slice(0, 6).toLowerCase()}`
444
+ const fmt = colorFormat.value
445
+
446
+ if (fmt === 'hex' || fmt === 'css') {
447
+ const parsed = parseColor(s)
448
+ if (!parsed) return null
449
+ if (fmt === 'hex') parsed.a = keepAlpha
450
+ return parsed
451
+ }
452
+
453
+ if (fmt === 'rgb') {
454
+ const parts = s.split(/[,\s]+/).filter(Boolean)
455
+ if (parts.length < 3) return null
456
+ const r = Number(parts[0])
457
+ const g = Number(parts[1])
458
+ const b = Number(parts[2])
459
+ if (![r, g, b].every(Number.isFinite)) return null
460
+ return { r: clamp(r, 0, 255), g: clamp(g, 0, 255), b: clamp(b, 0, 255), a: keepAlpha }
461
+ }
462
+
463
+ if (fmt === 'hsl') {
464
+ const parts = s.replace(/%/g, '').split(/[,\s]+/).filter(Boolean)
465
+ if (parts.length < 3) return null
466
+ const h = Number(parts[0])
467
+ const satPct = Number(parts[1])
468
+ const lPct = Number(parts[2])
469
+ if (![h, satPct, lPct].every(Number.isFinite)) return null
470
+ const rgb = hslToRgb(h, clamp(satPct, 0, 100) / 100, clamp(lPct, 0, 100) / 100)
471
+ return { ...rgb, a: keepAlpha }
472
+ }
473
+
474
+ if (fmt === 'hsb') {
475
+ const parts = s.replace(/%/g, '').split(/[,\s]+/).filter(Boolean)
476
+ if (parts.length < 3) return null
477
+ const h = Number(parts[0])
478
+ const sPct = Number(parts[1])
479
+ const bPct = Number(parts[2])
480
+ if (![h, sPct, bPct].every(Number.isFinite)) return null
481
+ const rgb = hsvToRgb(h, clamp(sPct, 0, 100) / 100, clamp(bPct, 0, 100) / 100)
482
+ return { ...rgb, a: keepAlpha }
483
+ }
484
+
75
485
  return null
76
486
  }
77
487
 
78
- const normalized = computed(() => parseHex(display.value))
488
+ function currentRgb() {
489
+ return hsvToRgb(hue.value, sat.value, val.value)
490
+ }
491
+
492
+ function currentHex(): string {
493
+ const { r, g, b } = currentRgb()
494
+ return rgbToHex(r, g, b, alpha.value)
495
+ }
496
+
497
+ function formatPanelValue(): string {
498
+ const { r, g, b } = currentRgb()
499
+ const rr = Math.round(r)
500
+ const gg = Math.round(g)
501
+ const bb = Math.round(b)
502
+ const a = alpha.value
503
+ switch (colorFormat.value) {
504
+ case 'rgb':
505
+ return `${rr}, ${gg}, ${bb}`
506
+ case 'css':
507
+ return a < 1 - 1e-6
508
+ ? `rgba(${rr}, ${gg}, ${bb}, ${Number(a.toFixed(2))})`
509
+ : `#${toHex2(rr)}${toHex2(gg)}${toHex2(bb)}`
510
+ case 'hsl': {
511
+ const hsl = rgbToHsl(rr, gg, bb)
512
+ return `${Math.round(hsl.h)}, ${Math.round(hsl.s * 100)}%, ${Math.round(hsl.l * 100)}%`
513
+ }
514
+ case 'hsb':
515
+ return `${Math.round(hue.value)}, ${Math.round(sat.value * 100)}%, ${Math.round(val.value * 100)}%`
516
+ case 'hex':
517
+ default:
518
+ return `${toHex2(rr)}${toHex2(gg)}${toHex2(bb)}`
519
+ }
520
+ }
521
+
522
+ const cssColor = computed(() => {
523
+ const parsed = parseColor(props.modelValue ?? '')
524
+ if (!parsed && !(props.modelValue ?? '').trim()) return 'transparent'
525
+ if (!parsed) return 'transparent'
526
+ const { r, g, b, a } = parsed
527
+ return a < 1 ? `rgba(${r},${g},${b},${a})` : `rgb(${r},${g},${b})`
528
+ })
79
529
 
80
- const cssColor = computed(() => normalized.value || 'transparent')
530
+ const svBg = computed(() => {
531
+ const { r, g, b } = hsvToRgb(hue.value, 1, 1)
532
+ return `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, rgb(${Math.round(r)},${Math.round(g)},${Math.round(b)}))`
533
+ })
81
534
 
82
- const pickerValue = computed(() => normalized.value || FALLBACK)
535
+ const alphaBg = computed(() => {
536
+ const { r, g, b } = currentRgb()
537
+ const c = `rgb(${Math.round(r)},${Math.round(g)},${Math.round(b)})`
538
+ return `linear-gradient(to right, transparent, ${c})`
539
+ })
540
+
541
+ const svCursorStyle = computed(() => ({
542
+ left: `${sat.value * 100}%`,
543
+ top: `${(1 - val.value) * 100}%`,
544
+ }))
545
+
546
+ function syncTextsFromState() {
547
+ const hex = currentHex()
548
+ const bare = hex.startsWith('#') ? hex.slice(1) : hex
549
+ inlineHex.value = bare.slice(0, 6)
550
+ panelValue.value = formatPanelValue()
551
+ alphaPctText.value = `${Math.round(alpha.value * 100)}%`
552
+ }
553
+
554
+ function applyRgba(c: Rgba, emitValue = true) {
555
+ const hsv = rgbToHsv(c.r, c.g, c.b)
556
+ hue.value = hsv.h
557
+ sat.value = hsv.s
558
+ val.value = hsv.v
559
+ alpha.value = clamp(c.a, 0, 1)
560
+ syncTextsFromState()
561
+ if (emitValue) commit(currentHex())
562
+ }
563
+
564
+ function loadFromModel(raw: string | null | undefined) {
565
+ const s = raw ?? ''
566
+ if (!s.trim()) {
567
+ hue.value = 0
568
+ sat.value = 0
569
+ val.value = 0
570
+ alpha.value = 1
571
+ inlineHex.value = ''
572
+ panelValue.value = ''
573
+ alphaPctText.value = '100%'
574
+ return
575
+ }
576
+ const parsed = parseColor(s)
577
+ if (!parsed) {
578
+ inlineHex.value = s.startsWith('#') ? s.slice(1) : s
579
+ return
580
+ }
581
+ const hsv = rgbToHsv(parsed.r, parsed.g, parsed.b)
582
+ hue.value = hsv.h
583
+ sat.value = hsv.s
584
+ val.value = hsv.v
585
+ alpha.value = parsed.a
586
+ syncTextsFromState()
587
+ }
588
+
589
+ watch(
590
+ () => props.modelValue,
591
+ (v) => {
592
+ if (open.value) {
593
+ const cur = currentHex()
594
+ const parsed = parseColor(v ?? '')
595
+ if (parsed && rgbToHex(parsed.r, parsed.g, parsed.b, parsed.a) === cur) return
596
+ }
597
+ loadFromModel(v)
598
+ },
599
+ { immediate: true },
600
+ )
83
601
 
84
602
  function commit(v: string) {
85
- display.value = v
86
603
  emit('update:modelValue', v)
87
604
  emit('change', v)
88
605
  }
89
606
 
90
- function onPicker(e: Event) {
91
- const v = (e.target as HTMLInputElement).value
92
- commit(v.toLowerCase())
607
+ function commitFromState() {
608
+ syncTextsFromState()
609
+ commit(currentHex())
93
610
  }
94
611
 
95
- function onText(e: Event) {
612
+ function onInlineInput(e: Event) {
96
613
  const v = (e.target as HTMLInputElement).value
97
- display.value = v
98
- emit('update:modelValue', v)
99
- emit('change', v)
614
+ inlineHex.value = v.replace(/^#/, '')
615
+ const parsed = parseColor(inlineHex.value)
616
+ if (parsed) {
617
+ parsed.a = alpha.value
618
+ const hsv = rgbToHsv(parsed.r, parsed.g, parsed.b)
619
+ hue.value = hsv.h
620
+ sat.value = hsv.s
621
+ val.value = hsv.v
622
+ panelValue.value = formatPanelValue()
623
+ commit(currentHex())
624
+ } else {
625
+ const raw = inlineHex.value.trim()
626
+ commit(raw ? (raw.startsWith('#') ? raw : `#${raw}`) : '')
627
+ }
100
628
  }
101
629
 
102
- function onBlur() {
103
- const n = parseHex(display.value)
104
- if (display.value.trim() === '') {
630
+ function onInlineBlur() {
631
+ const t = inlineHex.value.trim()
632
+ if (!t) {
105
633
  commit('')
634
+ syncTextsFromState()
106
635
  return
107
636
  }
108
- if (n && n !== display.value) commit(n)
637
+ const parsed = parseColor(t)
638
+ if (parsed) {
639
+ parsed.a = alpha.value
640
+ applyRgba(parsed, true)
641
+ } else {
642
+ loadFromModel(props.modelValue)
643
+ }
644
+ }
645
+
646
+ function onPanelValueInput(e: Event) {
647
+ panelValue.value = (e.target as HTMLInputElement).value
648
+ const parsed = parsePanelValue(panelValue.value, alpha.value)
649
+ if (!parsed) return
650
+ const hsv = rgbToHsv(parsed.r, parsed.g, parsed.b)
651
+ hue.value = hsv.h
652
+ sat.value = hsv.s
653
+ val.value = hsv.v
654
+ if (colorFormat.value === 'css') alpha.value = parsed.a
655
+ const hex = currentHex()
656
+ inlineHex.value = hex.replace(/^#/, '').slice(0, 6)
657
+ alphaPctText.value = `${Math.round(alpha.value * 100)}%`
658
+ commit(hex)
659
+ }
660
+
661
+ function onPanelValueBlur() {
662
+ const parsed = parsePanelValue(panelValue.value, alpha.value)
663
+ if (parsed) {
664
+ if (colorFormat.value !== 'css') parsed.a = alpha.value
665
+ applyRgba(parsed, true)
666
+ } else {
667
+ syncTextsFromState()
668
+ }
669
+ }
670
+
671
+ function onAlphaPctInput(e: Event) {
672
+ alphaPctText.value = (e.target as HTMLInputElement).value
673
+ const n = Number(String(alphaPctText.value).replace(/%/g, '').trim())
674
+ if (!Number.isFinite(n)) return
675
+ alpha.value = clamp(n / 100, 0, 1)
676
+ commitFromState()
677
+ }
678
+
679
+ function onAlphaPctBlur() {
680
+ const n = Number(String(alphaPctText.value).replace(/%/g, '').trim())
681
+ if (Number.isFinite(n)) {
682
+ alpha.value = clamp(n / 100, 0, 1)
683
+ }
684
+ syncTextsFromState()
685
+ commit(currentHex())
686
+ }
687
+
688
+ function toggleFormatMenu() {
689
+ formatOpen.value = !formatOpen.value
690
+ }
691
+
692
+ function setFormat(id: ColorFormat) {
693
+ colorFormat.value = id
694
+ formatOpen.value = false
695
+ syncTextsFromState()
696
+ }
697
+
698
+ function ratioFromEvent(el: HTMLElement, e: PointerEvent, axis: 'x' | 'y' | 'both') {
699
+ const r = el.getBoundingClientRect()
700
+ const x = clamp((e.clientX - r.left) / r.width, 0, 1)
701
+ const y = clamp((e.clientY - r.top) / r.height, 0, 1)
702
+ if (axis === 'x') return { x, y: 0 }
703
+ if (axis === 'y') return { x: 0, y }
704
+ return { x, y }
705
+ }
706
+
707
+ function bindDrag(
708
+ el: HTMLElement,
709
+ e: PointerEvent,
710
+ onMove: (ev: PointerEvent) => void,
711
+ ) {
712
+ e.preventDefault()
713
+ el.setPointerCapture(e.pointerId)
714
+ onMove(e)
715
+ const move = (ev: PointerEvent) => onMove(ev)
716
+ const up = (ev: PointerEvent) => {
717
+ el.releasePointerCapture(ev.pointerId)
718
+ el.removeEventListener('pointermove', move)
719
+ el.removeEventListener('pointerup', up)
720
+ el.removeEventListener('pointercancel', up)
721
+ }
722
+ el.addEventListener('pointermove', move)
723
+ el.addEventListener('pointerup', up)
724
+ el.addEventListener('pointercancel', up)
725
+ }
726
+
727
+ function onSvPointer(e: PointerEvent) {
728
+ const el = svRef.value
729
+ if (!el) return
730
+ bindDrag(el, e, (ev) => {
731
+ const { x, y } = ratioFromEvent(el, ev, 'both')
732
+ sat.value = x
733
+ val.value = 1 - y
734
+ commitFromState()
735
+ })
736
+ }
737
+
738
+ function onHuePointer(e: PointerEvent) {
739
+ const el = hueRef.value
740
+ if (!el) return
741
+ bindDrag(el, e, (ev) => {
742
+ const { x } = ratioFromEvent(el, ev, 'x')
743
+ hue.value = x * 360
744
+ commitFromState()
745
+ })
746
+ }
747
+
748
+ function onAlphaPointer(e: PointerEvent) {
749
+ const el = alphaRef.value
750
+ if (!el) return
751
+ bindDrag(el, e, (ev) => {
752
+ const { x } = ratioFromEvent(el, ev, 'x')
753
+ alpha.value = x
754
+ commitFromState()
755
+ })
756
+ }
757
+
758
+ async function pickEyeDropper() {
759
+ const ED = (
760
+ window as unknown as {
761
+ EyeDropper?: new () => { open: () => Promise<{ sRGBHex: string }> }
762
+ }
763
+ ).EyeDropper
764
+ if (!ED) return
765
+ try {
766
+ const result = await new ED().open()
767
+ const parsed = parseColor(result.sRGBHex)
768
+ if (parsed) {
769
+ parsed.a = alpha.value
770
+ applyRgba(parsed, true)
771
+ }
772
+ } catch {
773
+ /* 用户取消 */
774
+ }
775
+ }
776
+
777
+ function syncPanelPos() {
778
+ const el = triggerRef.value
779
+ if (!el) return
780
+ const r = el.getBoundingClientRect()
781
+ const width = 240
782
+ let left = r.left
783
+ if (left + width > window.innerWidth - 8) {
784
+ left = Math.max(8, window.innerWidth - width - 8)
785
+ }
786
+ let top = r.bottom + 6
787
+ const estH = 320
788
+ if (top + estH > window.innerHeight - 8) {
789
+ top = Math.max(8, r.top - estH - 6)
790
+ }
791
+ panelStyle.value = {
792
+ position: 'fixed',
793
+ top: `${top}px`,
794
+ left: `${left}px`,
795
+ width: `${width}px`,
796
+ zIndex: '100',
797
+ }
798
+ }
799
+
800
+ async function openPanel() {
801
+ if (props.disabled || suppressOpen.value) return
802
+ loadFromModel(props.modelValue)
803
+ formatOpen.value = false
804
+ open.value = true
805
+ await nextTick()
806
+ syncPanelPos()
807
+ }
808
+
809
+ function onPanelUnmountAutoFocus(event: Event) {
810
+ event.preventDefault()
811
+ }
812
+
813
+ function closePanel() {
814
+ if (!open.value) return
815
+ suppressOpen.value = true
816
+ formatOpen.value = false
817
+ open.value = false
818
+ nextTick(() => {
819
+ requestAnimationFrame(() => {
820
+ suppressOpen.value = false
821
+ })
822
+ })
823
+ }
824
+
825
+ async function toggle() {
826
+ if (props.disabled) return
827
+ if (open.value) {
828
+ closePanel()
829
+ return
830
+ }
831
+ await openPanel()
832
+ }
833
+
834
+ function onDocPointer(e: Event) {
835
+ if (!open.value) return
836
+ const t = e.target as Node | null
837
+ if (!t) return
838
+ if (rootRef.value?.contains(t)) return
839
+ if (panelRef.value?.contains(t)) return
840
+ closePanel()
841
+ }
842
+
843
+ function onWinChange() {
844
+ if (open.value) syncPanelPos()
109
845
  }
846
+
847
+ onMounted(() => {
848
+ hasEyeDropper.value = typeof window !== 'undefined' && 'EyeDropper' in window
849
+ document.addEventListener('pointerdown', onDocPointer, true)
850
+ window.addEventListener('resize', onWinChange)
851
+ window.addEventListener('scroll', onWinChange, true)
852
+ })
853
+
854
+ onUnmounted(() => {
855
+ document.removeEventListener('pointerdown', onDocPointer, true)
856
+ window.removeEventListener('resize', onWinChange)
857
+ window.removeEventListener('scroll', onWinChange, true)
858
+ })
110
859
  </script>
111
860
 
112
861
  <style lang="scss" scoped>
@@ -128,19 +877,35 @@ function onBlur() {
128
877
  flex-shrink: 0;
129
878
  width: var(--vm-control-height);
130
879
  height: var(--vm-control-height);
880
+ padding: 0;
131
881
  border: 1px solid var(--border);
132
882
  border-radius: var(--vm-control-radius);
133
883
  background: var(--muted);
134
884
  cursor: pointer;
135
885
  overflow: hidden;
886
+
887
+ &:hover:not(:disabled),
888
+ .is-open & {
889
+ border-color: var(--brand, #4e5dff);
890
+ }
891
+
892
+ &:disabled {
893
+ cursor: not-allowed;
894
+ }
895
+ }
896
+
897
+ .vm-color-picker.is-sm .vm-color-picker__swatch {
898
+ width: 16px;
899
+ height: 16px;
900
+ border-radius: 3px;
136
901
  }
137
902
 
138
903
  .vm-color-picker__swatch-fill {
139
904
  display: block;
140
905
  width: 100%;
141
906
  height: 100%;
142
- /* 无效色时显示棋盘,便于识别透明/空 */
143
- background-image: linear-gradient(45deg, #ccc 25%, transparent 25%),
907
+ background-image:
908
+ linear-gradient(45deg, #ccc 25%, transparent 25%),
144
909
  linear-gradient(-45deg, #ccc 25%, transparent 25%),
145
910
  linear-gradient(45deg, transparent 75%, #ccc 75%),
146
911
  linear-gradient(-45deg, transparent 75%, #ccc 75%);
@@ -152,17 +917,6 @@ function onBlur() {
152
917
  -4px 0;
153
918
  }
154
919
 
155
- .vm-color-picker__native {
156
- position: absolute;
157
- inset: 0;
158
- width: 100%;
159
- height: 100%;
160
- padding: 0;
161
- border: 0;
162
- opacity: 0;
163
- cursor: pointer;
164
- }
165
-
166
920
  .vm-color-picker__input {
167
921
  flex: 1;
168
922
  min-width: 0;
@@ -185,4 +939,263 @@ function onBlur() {
185
939
  color: var(--muted-foreground);
186
940
  }
187
941
  }
942
+
943
+ .vm-color-picker.is-sm .vm-color-picker__input {
944
+ height: 24px;
945
+ padding: 0 6px;
946
+ font-size: 12px;
947
+ border-radius: 4px;
948
+ }
949
+ </style>
950
+
951
+ <style lang="scss">
952
+ /* Teleport 到 body,不可 scoped */
953
+ .vm-color-picker__panel {
954
+ box-sizing: border-box;
955
+ padding: 12px;
956
+ border: 1px solid var(--border);
957
+ border-radius: 8px;
958
+ background: var(--popover, var(--background, #fff));
959
+ color: var(--foreground);
960
+ box-shadow:
961
+ 0 4px 6px -1px rgb(0 0 0 / 10%),
962
+ 0 10px 24px -4px rgb(0 0 0 / 12%);
963
+ user-select: none;
964
+ overflow: visible;
965
+ }
966
+
967
+ .vm-color-picker__sv {
968
+ position: relative;
969
+ width: 100%;
970
+ aspect-ratio: 1;
971
+ border-radius: 6px;
972
+ cursor: crosshair;
973
+ touch-action: none;
974
+ overflow: hidden;
975
+ }
976
+
977
+ .vm-color-picker__sv-cursor {
978
+ position: absolute;
979
+ width: 14px;
980
+ height: 14px;
981
+ margin: -7px 0 0 -7px;
982
+ border: 2px solid #fff;
983
+ border-radius: 50%;
984
+ box-shadow: 0 0 0 1px rgb(0 0 0 / 35%);
985
+ pointer-events: none;
986
+ }
987
+
988
+ .vm-color-picker__sliders {
989
+ display: flex;
990
+ align-items: center;
991
+ gap: 10px;
992
+ margin-top: 12px;
993
+ }
994
+
995
+ .vm-color-picker__eyedropper {
996
+ flex-shrink: 0;
997
+ display: inline-flex;
998
+ align-items: center;
999
+ justify-content: center;
1000
+ width: 28px;
1001
+ height: 28px;
1002
+ padding: 0;
1003
+ border: 1px solid var(--border);
1004
+ border-radius: 6px;
1005
+ background: var(--muted);
1006
+ color: var(--foreground);
1007
+ cursor: pointer;
1008
+
1009
+ &:hover {
1010
+ border-color: var(--brand, #4e5dff);
1011
+ color: var(--brand, #4e5dff);
1012
+ }
1013
+ }
1014
+
1015
+ .vm-color-picker__eyedropper-ico {
1016
+ display: block;
1017
+ }
1018
+
1019
+ .vm-color-picker__slider-col {
1020
+ flex: 1;
1021
+ min-width: 0;
1022
+ display: flex;
1023
+ flex-direction: column;
1024
+ gap: 8px;
1025
+ }
1026
+
1027
+ .vm-color-picker__hue,
1028
+ .vm-color-picker__alpha {
1029
+ position: relative;
1030
+ height: 12px;
1031
+ border-radius: 999px;
1032
+ cursor: pointer;
1033
+ touch-action: none;
1034
+ /* 允许拇指画出轨道高度,但水平用 thumbLeft 钳在内 */
1035
+ overflow: visible;
1036
+ }
1037
+
1038
+ .vm-color-picker__hue {
1039
+ background: linear-gradient(
1040
+ to right,
1041
+ #f00 0%,
1042
+ #ff0 17%,
1043
+ #0f0 33%,
1044
+ #0ff 50%,
1045
+ #00f 67%,
1046
+ #f0f 83%,
1047
+ #f00 100%
1048
+ );
1049
+ }
1050
+
1051
+ .vm-color-picker__alpha {
1052
+ background-image:
1053
+ linear-gradient(45deg, #c8c8c8 25%, transparent 25%),
1054
+ linear-gradient(-45deg, #c8c8c8 25%, transparent 25%),
1055
+ linear-gradient(45deg, transparent 75%, #c8c8c8 75%),
1056
+ linear-gradient(-45deg, transparent 75%, #c8c8c8 75%);
1057
+ background-size: 8px 8px;
1058
+ background-position:
1059
+ 0 0,
1060
+ 0 4px,
1061
+ 4px -4px,
1062
+ -4px 0;
1063
+ background-color: #fff;
1064
+ }
1065
+
1066
+ .vm-color-picker__alpha-fill {
1067
+ position: absolute;
1068
+ inset: 0;
1069
+ border-radius: inherit;
1070
+ pointer-events: none;
1071
+ overflow: hidden;
1072
+ }
1073
+
1074
+ .vm-color-picker__slider-thumb {
1075
+ position: absolute;
1076
+ top: 50%;
1077
+ width: 14px;
1078
+ height: 14px;
1079
+ transform: translate(-50%, -50%);
1080
+ border: 2px solid #fff;
1081
+ border-radius: 50%;
1082
+ background: transparent;
1083
+ box-shadow: 0 0 0 1px rgb(0 0 0 / 35%);
1084
+ pointer-events: none;
1085
+ box-sizing: border-box;
1086
+ }
1087
+
1088
+ .vm-color-picker__footer {
1089
+ position: relative;
1090
+ display: flex;
1091
+ align-items: center;
1092
+ gap: 6px;
1093
+ margin-top: 12px;
1094
+ }
1095
+
1096
+ .vm-color-picker__format-wrap {
1097
+ position: relative;
1098
+ flex-shrink: 0;
1099
+ }
1100
+
1101
+ .vm-color-picker__format {
1102
+ display: inline-flex;
1103
+ align-items: center;
1104
+ gap: 2px;
1105
+ height: 28px;
1106
+ padding: 0 8px;
1107
+ border: 1px solid var(--border);
1108
+ border-radius: 6px;
1109
+ background: var(--muted);
1110
+ color: var(--foreground);
1111
+ font-size: 12px;
1112
+ line-height: 1;
1113
+ cursor: pointer;
1114
+
1115
+ &:hover,
1116
+ &.is-open {
1117
+ border-color: var(--brand, #4e5dff);
1118
+ }
1119
+
1120
+ svg {
1121
+ opacity: 0.7;
1122
+ }
1123
+ }
1124
+
1125
+ .vm-color-picker__format-menu {
1126
+ position: absolute;
1127
+ left: 0;
1128
+ bottom: calc(100% + 4px);
1129
+ z-index: 2;
1130
+ min-width: 96px;
1131
+ padding: 4px;
1132
+ border: 1px solid var(--border);
1133
+ border-radius: 8px;
1134
+ background: var(--popover, var(--background, #fff));
1135
+ box-shadow:
1136
+ 0 4px 6px -1px rgb(0 0 0 / 10%),
1137
+ 0 10px 20px -4px rgb(0 0 0 / 12%);
1138
+ }
1139
+
1140
+ .vm-color-picker__format-item {
1141
+ display: flex;
1142
+ align-items: center;
1143
+ gap: 6px;
1144
+ width: 100%;
1145
+ height: 28px;
1146
+ padding: 0 8px;
1147
+ border: none;
1148
+ border-radius: 6px;
1149
+ background: transparent;
1150
+ color: var(--foreground);
1151
+ font-size: 12px;
1152
+ text-align: left;
1153
+ cursor: pointer;
1154
+
1155
+ &:hover {
1156
+ background: color-mix(in srgb, var(--brand, #4e5dff) 12%, transparent);
1157
+ }
1158
+
1159
+ &.is-active {
1160
+ background: var(--brand, #4e5dff);
1161
+ color: #fff;
1162
+ }
1163
+ }
1164
+
1165
+ .vm-color-picker__format-check {
1166
+ display: inline-flex;
1167
+ width: 14px;
1168
+ height: 14px;
1169
+ flex-shrink: 0;
1170
+ align-items: center;
1171
+ justify-content: center;
1172
+ }
1173
+
1174
+ .vm-color-picker__value,
1175
+ .vm-color-picker__alpha-pct {
1176
+ height: 28px;
1177
+ padding: 0 8px;
1178
+ border: 1px solid var(--border);
1179
+ border-radius: 6px;
1180
+ background: var(--muted);
1181
+ color: var(--foreground);
1182
+ font-size: 12px;
1183
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
1184
+ outline: none;
1185
+
1186
+ &:focus {
1187
+ border-color: var(--brand, #4e5dff);
1188
+ }
1189
+ }
1190
+
1191
+ .vm-color-picker__value {
1192
+ flex: 1;
1193
+ min-width: 0;
1194
+ }
1195
+
1196
+ .vm-color-picker__alpha-pct {
1197
+ width: 52px;
1198
+ flex-shrink: 0;
1199
+ text-align: center;
1200
+ }
188
1201
  </style>