@363045841yyt/klinechart 0.7.4 → 0.7.5-alpha.2
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 +149 -153
- package/dist/index.cjs +2 -2
- package/dist/index.js +15 -9
- package/dist/klinechart.css +1 -1
- package/package.json +82 -76
- package/src/__tests__/_mockController.ts +192 -192
- package/src/__tests__/contract.test.ts +132 -132
- package/src/components/DrawingStyleToolbar.vue +199 -199
- package/src/components/IndicatorParams.vue +570 -570
- package/src/components/IndicatorSelector.vue +1169 -1169
- package/src/components/KLineChart.vue +1570 -1570
- package/src/components/KLineTooltip.vue +200 -200
- package/src/components/LeftToolbar.vue +844 -844
- package/src/components/MarkerTooltip.vue +155 -155
- package/src/components/index.ts +7 -7
- package/src/composables/useFullscreenTeleportTarget.ts +18 -18
- package/src/debug/canvasProfiler.ts +296 -296
- package/src/index.ts +402 -402
- package/src/version.ts +3 -3
|
@@ -1,132 +1,132 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Contract test for @363045841yyt/klinechart.
|
|
3
|
-
*
|
|
4
|
-
* Phase 1D agent's brief: make these pass without weakening assertions,
|
|
5
|
-
* preserving the legacy KMapPlugin.install signature.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
9
|
-
import { defineComponent, h, nextTick, ref, shallowRef } from 'vue'
|
|
10
|
-
import { mount } from '@vue/test-utils'
|
|
11
|
-
import * as VueAdapter from '../index'
|
|
12
|
-
import { coreSignalToVueRef } from '../index'
|
|
13
|
-
import { createMockChartController, createTestSignal } from './_mockController'
|
|
14
|
-
|
|
15
|
-
describe('@363045841yyt/klinechart �?public API surface', () => {
|
|
16
|
-
it('exports createChart, useChart, useIndicatorSelector, KLineChart, KMapPlugin', () => {
|
|
17
|
-
expect(typeof VueAdapter.createChart).toBe('function')
|
|
18
|
-
expect(typeof VueAdapter.useChart).toBe('function')
|
|
19
|
-
expect(typeof VueAdapter.useIndicatorSelector).toBe('function')
|
|
20
|
-
expect(VueAdapter.KLineChart).toBeDefined()
|
|
21
|
-
expect(typeof VueAdapter.KMapPlugin.install).toBe('function')
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
it('KMapPlugin.install is callable with a mock app and registers KLineChart', () => {
|
|
25
|
-
const registered: Record<string, unknown> = {}
|
|
26
|
-
const mockApp = {
|
|
27
|
-
component(name: string, comp: unknown) {
|
|
28
|
-
registered[name] = comp
|
|
29
|
-
},
|
|
30
|
-
} as unknown as Parameters<typeof VueAdapter.KMapPlugin.install>[0]
|
|
31
|
-
VueAdapter.KMapPlugin.install(mockApp)
|
|
32
|
-
expect(registered.KLineChart).toBe(VueAdapter.KLineChart)
|
|
33
|
-
})
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
describe('@363045841yyt/klinechart �?SSR safety', () => {
|
|
37
|
-
it('module import does not touch window or document', () => {
|
|
38
|
-
// Import above ran in node env without jsdom. If it touched window, this
|
|
39
|
-
// file would not have loaded. Test documents the contract.
|
|
40
|
-
expect(true).toBe(true)
|
|
41
|
-
})
|
|
42
|
-
})
|
|
43
|
-
|
|
44
|
-
describe('@363045841yyt/klinechart �?useChart lifecycle', () => {
|
|
45
|
-
afterEach(() => {
|
|
46
|
-
// Reset the injected factory so other tests start clean.
|
|
47
|
-
VueAdapter.__setControllerFactory(null)
|
|
48
|
-
})
|
|
49
|
-
|
|
50
|
-
it('mounts on first render via template ref', async () => {
|
|
51
|
-
const mockController = createMockChartController({ data: [] })
|
|
52
|
-
const factorySpy = vi.fn(() => mockController)
|
|
53
|
-
VueAdapter.__setControllerFactory(factorySpy)
|
|
54
|
-
|
|
55
|
-
const HostComponent = defineComponent({
|
|
56
|
-
name: 'Host',
|
|
57
|
-
setup() {
|
|
58
|
-
const containerRef = ref<HTMLElement | null>(null)
|
|
59
|
-
const { chart } = VueAdapter.useChart(containerRef, { data: [] })
|
|
60
|
-
return { containerRef, chart }
|
|
61
|
-
},
|
|
62
|
-
render() {
|
|
63
|
-
return h('div', { ref: 'containerRef' })
|
|
64
|
-
},
|
|
65
|
-
})
|
|
66
|
-
|
|
67
|
-
const wrapper = mount(HostComponent, { attachTo: document.body })
|
|
68
|
-
await nextTick()
|
|
69
|
-
|
|
70
|
-
expect(factorySpy).toHaveBeenCalledTimes(1)
|
|
71
|
-
const factoryArg = factorySpy.mock.calls[0]?.[0]
|
|
72
|
-
expect(factoryArg?.container).toBeInstanceOf(HTMLElement)
|
|
73
|
-
expect(wrapper.vm.chart).toBe(mockController)
|
|
74
|
-
|
|
75
|
-
wrapper.unmount()
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
it('disposes on unmount', async () => {
|
|
79
|
-
const mockController = createMockChartController({ data: [] })
|
|
80
|
-
VueAdapter.__setControllerFactory(() => mockController)
|
|
81
|
-
|
|
82
|
-
const HostComponent = defineComponent({
|
|
83
|
-
name: 'Host',
|
|
84
|
-
setup() {
|
|
85
|
-
const containerRef = ref<HTMLElement | null>(null)
|
|
86
|
-
const { chart } = VueAdapter.useChart(containerRef, { data: [] })
|
|
87
|
-
return { containerRef, chart }
|
|
88
|
-
},
|
|
89
|
-
render() {
|
|
90
|
-
return h('div', { ref: 'containerRef' })
|
|
91
|
-
},
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
const wrapper = mount(HostComponent, { attachTo: document.body })
|
|
95
|
-
await nextTick()
|
|
96
|
-
|
|
97
|
-
expect(mockController.disposeCalls()).toBe(0)
|
|
98
|
-
wrapper.unmount()
|
|
99
|
-
// Allow lifecycle hooks to settle.
|
|
100
|
-
await nextTick()
|
|
101
|
-
expect(mockController.disposeCalls()).toBe(1)
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
it('reactivity bridge: signal change updates returned ref', async () => {
|
|
105
|
-
// Mount a tiny scoped component so coreSignalToVueRef can register
|
|
106
|
-
// its onScopeDispose cleanup. Without a setup scope the ref is still
|
|
107
|
-
// wired up correctly, but cleanup would not be automatic.
|
|
108
|
-
const signal = createTestSignal<number>(1)
|
|
109
|
-
const bridgedRef = shallowRef<{ value: number } | null>(null)
|
|
110
|
-
|
|
111
|
-
const HostComponent = defineComponent({
|
|
112
|
-
name: 'BridgeHost',
|
|
113
|
-
setup() {
|
|
114
|
-
const r = coreSignalToVueRef(signal)
|
|
115
|
-
bridgedRef.value = r as unknown as { value: number }
|
|
116
|
-
return () => h('div', String(r.value))
|
|
117
|
-
},
|
|
118
|
-
})
|
|
119
|
-
|
|
120
|
-
const wrapper = mount(HostComponent, { attachTo: document.body })
|
|
121
|
-
expect(bridgedRef.value?.value).toBe(1)
|
|
122
|
-
expect(wrapper.text()).toBe('1')
|
|
123
|
-
|
|
124
|
-
signal.set(42)
|
|
125
|
-
await nextTick()
|
|
126
|
-
|
|
127
|
-
expect(bridgedRef.value?.value).toBe(42)
|
|
128
|
-
expect(wrapper.text()).toBe('42')
|
|
129
|
-
|
|
130
|
-
wrapper.unmount()
|
|
131
|
-
})
|
|
132
|
-
})
|
|
1
|
+
/**
|
|
2
|
+
* Contract test for @363045841yyt/klinechart.
|
|
3
|
+
*
|
|
4
|
+
* Phase 1D agent's brief: make these pass without weakening assertions,
|
|
5
|
+
* preserving the legacy KMapPlugin.install signature.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
9
|
+
import { defineComponent, h, nextTick, ref, shallowRef } from 'vue'
|
|
10
|
+
import { mount } from '@vue/test-utils'
|
|
11
|
+
import * as VueAdapter from '../index'
|
|
12
|
+
import { coreSignalToVueRef } from '../index'
|
|
13
|
+
import { createMockChartController, createTestSignal } from './_mockController'
|
|
14
|
+
|
|
15
|
+
describe('@363045841yyt/klinechart �?public API surface', () => {
|
|
16
|
+
it('exports createChart, useChart, useIndicatorSelector, KLineChart, KMapPlugin', () => {
|
|
17
|
+
expect(typeof VueAdapter.createChart).toBe('function')
|
|
18
|
+
expect(typeof VueAdapter.useChart).toBe('function')
|
|
19
|
+
expect(typeof VueAdapter.useIndicatorSelector).toBe('function')
|
|
20
|
+
expect(VueAdapter.KLineChart).toBeDefined()
|
|
21
|
+
expect(typeof VueAdapter.KMapPlugin.install).toBe('function')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('KMapPlugin.install is callable with a mock app and registers KLineChart', () => {
|
|
25
|
+
const registered: Record<string, unknown> = {}
|
|
26
|
+
const mockApp = {
|
|
27
|
+
component(name: string, comp: unknown) {
|
|
28
|
+
registered[name] = comp
|
|
29
|
+
},
|
|
30
|
+
} as unknown as Parameters<typeof VueAdapter.KMapPlugin.install>[0]
|
|
31
|
+
VueAdapter.KMapPlugin.install(mockApp)
|
|
32
|
+
expect(registered.KLineChart).toBe(VueAdapter.KLineChart)
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('@363045841yyt/klinechart �?SSR safety', () => {
|
|
37
|
+
it('module import does not touch window or document', () => {
|
|
38
|
+
// Import above ran in node env without jsdom. If it touched window, this
|
|
39
|
+
// file would not have loaded. Test documents the contract.
|
|
40
|
+
expect(true).toBe(true)
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('@363045841yyt/klinechart �?useChart lifecycle', () => {
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
// Reset the injected factory so other tests start clean.
|
|
47
|
+
VueAdapter.__setControllerFactory(null)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('mounts on first render via template ref', async () => {
|
|
51
|
+
const mockController = createMockChartController({ data: [] })
|
|
52
|
+
const factorySpy = vi.fn(() => mockController)
|
|
53
|
+
VueAdapter.__setControllerFactory(factorySpy)
|
|
54
|
+
|
|
55
|
+
const HostComponent = defineComponent({
|
|
56
|
+
name: 'Host',
|
|
57
|
+
setup() {
|
|
58
|
+
const containerRef = ref<HTMLElement | null>(null)
|
|
59
|
+
const { chart } = VueAdapter.useChart(containerRef, { data: [] })
|
|
60
|
+
return { containerRef, chart }
|
|
61
|
+
},
|
|
62
|
+
render() {
|
|
63
|
+
return h('div', { ref: 'containerRef' })
|
|
64
|
+
},
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const wrapper = mount(HostComponent, { attachTo: document.body })
|
|
68
|
+
await nextTick()
|
|
69
|
+
|
|
70
|
+
expect(factorySpy).toHaveBeenCalledTimes(1)
|
|
71
|
+
const factoryArg = factorySpy.mock.calls[0]?.[0]
|
|
72
|
+
expect(factoryArg?.container).toBeInstanceOf(HTMLElement)
|
|
73
|
+
expect(wrapper.vm.chart).toBe(mockController)
|
|
74
|
+
|
|
75
|
+
wrapper.unmount()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('disposes on unmount', async () => {
|
|
79
|
+
const mockController = createMockChartController({ data: [] })
|
|
80
|
+
VueAdapter.__setControllerFactory(() => mockController)
|
|
81
|
+
|
|
82
|
+
const HostComponent = defineComponent({
|
|
83
|
+
name: 'Host',
|
|
84
|
+
setup() {
|
|
85
|
+
const containerRef = ref<HTMLElement | null>(null)
|
|
86
|
+
const { chart } = VueAdapter.useChart(containerRef, { data: [] })
|
|
87
|
+
return { containerRef, chart }
|
|
88
|
+
},
|
|
89
|
+
render() {
|
|
90
|
+
return h('div', { ref: 'containerRef' })
|
|
91
|
+
},
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const wrapper = mount(HostComponent, { attachTo: document.body })
|
|
95
|
+
await nextTick()
|
|
96
|
+
|
|
97
|
+
expect(mockController.disposeCalls()).toBe(0)
|
|
98
|
+
wrapper.unmount()
|
|
99
|
+
// Allow lifecycle hooks to settle.
|
|
100
|
+
await nextTick()
|
|
101
|
+
expect(mockController.disposeCalls()).toBe(1)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('reactivity bridge: signal change updates returned ref', async () => {
|
|
105
|
+
// Mount a tiny scoped component so coreSignalToVueRef can register
|
|
106
|
+
// its onScopeDispose cleanup. Without a setup scope the ref is still
|
|
107
|
+
// wired up correctly, but cleanup would not be automatic.
|
|
108
|
+
const signal = createTestSignal<number>(1)
|
|
109
|
+
const bridgedRef = shallowRef<{ value: number } | null>(null)
|
|
110
|
+
|
|
111
|
+
const HostComponent = defineComponent({
|
|
112
|
+
name: 'BridgeHost',
|
|
113
|
+
setup() {
|
|
114
|
+
const r = coreSignalToVueRef(signal)
|
|
115
|
+
bridgedRef.value = r as unknown as { value: number }
|
|
116
|
+
return () => h('div', String(r.value))
|
|
117
|
+
},
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
const wrapper = mount(HostComponent, { attachTo: document.body })
|
|
121
|
+
expect(bridgedRef.value?.value).toBe(1)
|
|
122
|
+
expect(wrapper.text()).toBe('1')
|
|
123
|
+
|
|
124
|
+
signal.set(42)
|
|
125
|
+
await nextTick()
|
|
126
|
+
|
|
127
|
+
expect(bridgedRef.value?.value).toBe(42)
|
|
128
|
+
expect(wrapper.text()).toBe('42')
|
|
129
|
+
|
|
130
|
+
wrapper.unmount()
|
|
131
|
+
})
|
|
132
|
+
})
|
|
@@ -1,199 +1,199 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
<div
|
|
3
|
-
class="drawing-style-toolbar"
|
|
4
|
-
@pointerdown.stop
|
|
5
|
-
@pointermove.stop
|
|
6
|
-
@pointerup.stop
|
|
7
|
-
>
|
|
8
|
-
<div class="toolbar-item color-item" title="颜色">
|
|
9
|
-
<span class="color-swatch" :style="{ background: drawing.style.stroke ?? '#2962ff' }"></span>
|
|
10
|
-
<input
|
|
11
|
-
type="color"
|
|
12
|
-
class="color-input"
|
|
13
|
-
:value="drawing.style.stroke ?? '#2962ff'"
|
|
14
|
-
@input="onColorChange(($event.target as HTMLInputElement).value)"
|
|
15
|
-
/>
|
|
16
|
-
</div>
|
|
17
|
-
|
|
18
|
-
<select
|
|
19
|
-
class="toolbar-select"
|
|
20
|
-
:value="drawing.style.strokeWidth ?? 1"
|
|
21
|
-
@change="onWidthChange(Number(($event.target as HTMLSelectElement).value))"
|
|
22
|
-
title="线宽"
|
|
23
|
-
>
|
|
24
|
-
<option :value="1">1px</option>
|
|
25
|
-
<option :value="2">2px</option>
|
|
26
|
-
<option :value="3">3px</option>
|
|
27
|
-
<option :value="4">4px</option>
|
|
28
|
-
</select>
|
|
29
|
-
|
|
30
|
-
<select
|
|
31
|
-
class="toolbar-select"
|
|
32
|
-
:value="drawing.style.strokeStyle ?? 'solid'"
|
|
33
|
-
@change="onLineStyleChange(($event.target as HTMLSelectElement).value as 'solid' | 'dashed' | 'dotted')"
|
|
34
|
-
title="线型"
|
|
35
|
-
>
|
|
36
|
-
<option value="solid">实线</option>
|
|
37
|
-
<option value="dashed">虚线</option>
|
|
38
|
-
<option value="dotted">点线</option>
|
|
39
|
-
</select>
|
|
40
|
-
|
|
41
|
-
<button type="button" class="toolbar-btn delete-btn" title="删除" @click="$emit('delete')">
|
|
42
|
-
<svg class="delete-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
43
|
-
<path d="M3 6h18" />
|
|
44
|
-
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
|
45
|
-
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
|
46
|
-
</svg>
|
|
47
|
-
</button>
|
|
48
|
-
</div>
|
|
49
|
-
</template>
|
|
50
|
-
|
|
51
|
-
<script setup lang="ts">
|
|
52
|
-
import { onMounted, onUnmounted } from 'vue'
|
|
53
|
-
|
|
54
|
-
export interface DrawingStyle {
|
|
55
|
-
stroke?: string
|
|
56
|
-
strokeWidth?: number
|
|
57
|
-
strokeStyle?: 'solid' | 'dashed' | 'dotted'
|
|
58
|
-
fill?: string
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export interface DrawingObject {
|
|
62
|
-
id: string
|
|
63
|
-
type: string
|
|
64
|
-
points: { x: number; y: number }[]
|
|
65
|
-
style: DrawingStyle
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const props = defineProps<{
|
|
69
|
-
drawing: DrawingObject
|
|
70
|
-
}>()
|
|
71
|
-
|
|
72
|
-
const emit = defineEmits<{
|
|
73
|
-
(e: 'updateStyle', style: Partial<DrawingStyle>): void
|
|
74
|
-
(e: 'delete'): void
|
|
75
|
-
}>()
|
|
76
|
-
|
|
77
|
-
function onKeyDown(e: KeyboardEvent) {
|
|
78
|
-
if (e.key === 'Delete') {
|
|
79
|
-
e.preventDefault()
|
|
80
|
-
emit('delete')
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
onMounted(() => document.addEventListener('keydown', onKeyDown))
|
|
85
|
-
onUnmounted(() => document.removeEventListener('keydown', onKeyDown))
|
|
86
|
-
|
|
87
|
-
function onColorChange(color: string) {
|
|
88
|
-
emit('updateStyle', { stroke: color })
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function onWidthChange(width: number) {
|
|
92
|
-
emit('updateStyle', { strokeWidth: width })
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function onLineStyleChange(style: 'solid' | 'dashed' | 'dotted') {
|
|
96
|
-
emit('updateStyle', { strokeStyle: style })
|
|
97
|
-
}
|
|
98
|
-
</script>
|
|
99
|
-
|
|
100
|
-
<style scoped>
|
|
101
|
-
.drawing-style-toolbar {
|
|
102
|
-
position: absolute;
|
|
103
|
-
left: 50%;
|
|
104
|
-
top: 8px;
|
|
105
|
-
transform: translateX(-50%);
|
|
106
|
-
display: flex;
|
|
107
|
-
align-items: center;
|
|
108
|
-
gap: 6px;
|
|
109
|
-
padding: 4px 8px;
|
|
110
|
-
height: 32px;
|
|
111
|
-
background: rgba(250, 251, 252, 0.88);
|
|
112
|
-
backdrop-filter: blur(8px);
|
|
113
|
-
-webkit-backdrop-filter: blur(8px);
|
|
114
|
-
border: 1px solid #e5e7eb;
|
|
115
|
-
border-radius: 6px;
|
|
116
|
-
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
|
117
|
-
z-index: 100;
|
|
118
|
-
user-select: none;
|
|
119
|
-
pointer-events: auto;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
.toolbar-item {
|
|
123
|
-
display: inline-flex;
|
|
124
|
-
align-items: center;
|
|
125
|
-
justify-content: center;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
.color-item {
|
|
129
|
-
position: relative;
|
|
130
|
-
width: 24px;
|
|
131
|
-
height: 24px;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
.color-swatch {
|
|
135
|
-
display: block;
|
|
136
|
-
width: 100%;
|
|
137
|
-
height: 100%;
|
|
138
|
-
border: 1px solid #d1d5db;
|
|
139
|
-
border-radius: 4px;
|
|
140
|
-
cursor: pointer;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
.color-input {
|
|
144
|
-
position: absolute;
|
|
145
|
-
inset: 0;
|
|
146
|
-
opacity: 0;
|
|
147
|
-
cursor: pointer;
|
|
148
|
-
width: 100%;
|
|
149
|
-
height: 100%;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
.toolbar-select {
|
|
153
|
-
height: 24px;
|
|
154
|
-
padding: 0 4px;
|
|
155
|
-
border: 1px solid #d1d5db;
|
|
156
|
-
border-radius: 4px;
|
|
157
|
-
background: #fff;
|
|
158
|
-
color: #374151;
|
|
159
|
-
font-size: 12px;
|
|
160
|
-
cursor: pointer;
|
|
161
|
-
outline: none;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
.toolbar-select:hover {
|
|
165
|
-
border-color: #9ca3af;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
.toolbar-btn {
|
|
169
|
-
display: inline-flex;
|
|
170
|
-
align-items: center;
|
|
171
|
-
justify-content: center;
|
|
172
|
-
width: 24px;
|
|
173
|
-
height: 24px;
|
|
174
|
-
padding: 0;
|
|
175
|
-
border: 1px solid transparent;
|
|
176
|
-
border-radius: 4px;
|
|
177
|
-
background: transparent;
|
|
178
|
-
color: #6b7280;
|
|
179
|
-
cursor: pointer;
|
|
180
|
-
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
.toolbar-btn:hover {
|
|
184
|
-
border-color: #d1d5db;
|
|
185
|
-
background: #f3f4f6;
|
|
186
|
-
color: #374151;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
.delete-btn:hover {
|
|
190
|
-
color: #dc2626;
|
|
191
|
-
border-color: #fca5a5;
|
|
192
|
-
background: #fef2f2;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
.delete-icon {
|
|
196
|
-
width: 14px;
|
|
197
|
-
height: 14px;
|
|
198
|
-
}
|
|
199
|
-
</style>
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
class="drawing-style-toolbar"
|
|
4
|
+
@pointerdown.stop
|
|
5
|
+
@pointermove.stop
|
|
6
|
+
@pointerup.stop
|
|
7
|
+
>
|
|
8
|
+
<div class="toolbar-item color-item" title="颜色">
|
|
9
|
+
<span class="color-swatch" :style="{ background: drawing.style.stroke ?? '#2962ff' }"></span>
|
|
10
|
+
<input
|
|
11
|
+
type="color"
|
|
12
|
+
class="color-input"
|
|
13
|
+
:value="drawing.style.stroke ?? '#2962ff'"
|
|
14
|
+
@input="onColorChange(($event.target as HTMLInputElement).value)"
|
|
15
|
+
/>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<select
|
|
19
|
+
class="toolbar-select"
|
|
20
|
+
:value="drawing.style.strokeWidth ?? 1"
|
|
21
|
+
@change="onWidthChange(Number(($event.target as HTMLSelectElement).value))"
|
|
22
|
+
title="线宽"
|
|
23
|
+
>
|
|
24
|
+
<option :value="1">1px</option>
|
|
25
|
+
<option :value="2">2px</option>
|
|
26
|
+
<option :value="3">3px</option>
|
|
27
|
+
<option :value="4">4px</option>
|
|
28
|
+
</select>
|
|
29
|
+
|
|
30
|
+
<select
|
|
31
|
+
class="toolbar-select"
|
|
32
|
+
:value="drawing.style.strokeStyle ?? 'solid'"
|
|
33
|
+
@change="onLineStyleChange(($event.target as HTMLSelectElement).value as 'solid' | 'dashed' | 'dotted')"
|
|
34
|
+
title="线型"
|
|
35
|
+
>
|
|
36
|
+
<option value="solid">实线</option>
|
|
37
|
+
<option value="dashed">虚线</option>
|
|
38
|
+
<option value="dotted">点线</option>
|
|
39
|
+
</select>
|
|
40
|
+
|
|
41
|
+
<button type="button" class="toolbar-btn delete-btn" title="删除" @click="$emit('delete')">
|
|
42
|
+
<svg class="delete-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
43
|
+
<path d="M3 6h18" />
|
|
44
|
+
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
|
45
|
+
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
|
46
|
+
</svg>
|
|
47
|
+
</button>
|
|
48
|
+
</div>
|
|
49
|
+
</template>
|
|
50
|
+
|
|
51
|
+
<script setup lang="ts">
|
|
52
|
+
import { onMounted, onUnmounted } from 'vue'
|
|
53
|
+
|
|
54
|
+
export interface DrawingStyle {
|
|
55
|
+
stroke?: string
|
|
56
|
+
strokeWidth?: number
|
|
57
|
+
strokeStyle?: 'solid' | 'dashed' | 'dotted'
|
|
58
|
+
fill?: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface DrawingObject {
|
|
62
|
+
id: string
|
|
63
|
+
type: string
|
|
64
|
+
points: { x: number; y: number }[]
|
|
65
|
+
style: DrawingStyle
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const props = defineProps<{
|
|
69
|
+
drawing: DrawingObject
|
|
70
|
+
}>()
|
|
71
|
+
|
|
72
|
+
const emit = defineEmits<{
|
|
73
|
+
(e: 'updateStyle', style: Partial<DrawingStyle>): void
|
|
74
|
+
(e: 'delete'): void
|
|
75
|
+
}>()
|
|
76
|
+
|
|
77
|
+
function onKeyDown(e: KeyboardEvent) {
|
|
78
|
+
if (e.key === 'Delete') {
|
|
79
|
+
e.preventDefault()
|
|
80
|
+
emit('delete')
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
onMounted(() => document.addEventListener('keydown', onKeyDown))
|
|
85
|
+
onUnmounted(() => document.removeEventListener('keydown', onKeyDown))
|
|
86
|
+
|
|
87
|
+
function onColorChange(color: string) {
|
|
88
|
+
emit('updateStyle', { stroke: color })
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function onWidthChange(width: number) {
|
|
92
|
+
emit('updateStyle', { strokeWidth: width })
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function onLineStyleChange(style: 'solid' | 'dashed' | 'dotted') {
|
|
96
|
+
emit('updateStyle', { strokeStyle: style })
|
|
97
|
+
}
|
|
98
|
+
</script>
|
|
99
|
+
|
|
100
|
+
<style scoped>
|
|
101
|
+
.drawing-style-toolbar {
|
|
102
|
+
position: absolute;
|
|
103
|
+
left: 50%;
|
|
104
|
+
top: 8px;
|
|
105
|
+
transform: translateX(-50%);
|
|
106
|
+
display: flex;
|
|
107
|
+
align-items: center;
|
|
108
|
+
gap: 6px;
|
|
109
|
+
padding: 4px 8px;
|
|
110
|
+
height: 32px;
|
|
111
|
+
background: rgba(250, 251, 252, 0.88);
|
|
112
|
+
backdrop-filter: blur(8px);
|
|
113
|
+
-webkit-backdrop-filter: blur(8px);
|
|
114
|
+
border: 1px solid #e5e7eb;
|
|
115
|
+
border-radius: 6px;
|
|
116
|
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
|
117
|
+
z-index: 100;
|
|
118
|
+
user-select: none;
|
|
119
|
+
pointer-events: auto;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.toolbar-item {
|
|
123
|
+
display: inline-flex;
|
|
124
|
+
align-items: center;
|
|
125
|
+
justify-content: center;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.color-item {
|
|
129
|
+
position: relative;
|
|
130
|
+
width: 24px;
|
|
131
|
+
height: 24px;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.color-swatch {
|
|
135
|
+
display: block;
|
|
136
|
+
width: 100%;
|
|
137
|
+
height: 100%;
|
|
138
|
+
border: 1px solid #d1d5db;
|
|
139
|
+
border-radius: 4px;
|
|
140
|
+
cursor: pointer;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.color-input {
|
|
144
|
+
position: absolute;
|
|
145
|
+
inset: 0;
|
|
146
|
+
opacity: 0;
|
|
147
|
+
cursor: pointer;
|
|
148
|
+
width: 100%;
|
|
149
|
+
height: 100%;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.toolbar-select {
|
|
153
|
+
height: 24px;
|
|
154
|
+
padding: 0 4px;
|
|
155
|
+
border: 1px solid #d1d5db;
|
|
156
|
+
border-radius: 4px;
|
|
157
|
+
background: #fff;
|
|
158
|
+
color: #374151;
|
|
159
|
+
font-size: 12px;
|
|
160
|
+
cursor: pointer;
|
|
161
|
+
outline: none;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.toolbar-select:hover {
|
|
165
|
+
border-color: #9ca3af;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.toolbar-btn {
|
|
169
|
+
display: inline-flex;
|
|
170
|
+
align-items: center;
|
|
171
|
+
justify-content: center;
|
|
172
|
+
width: 24px;
|
|
173
|
+
height: 24px;
|
|
174
|
+
padding: 0;
|
|
175
|
+
border: 1px solid transparent;
|
|
176
|
+
border-radius: 4px;
|
|
177
|
+
background: transparent;
|
|
178
|
+
color: #6b7280;
|
|
179
|
+
cursor: pointer;
|
|
180
|
+
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.toolbar-btn:hover {
|
|
184
|
+
border-color: #d1d5db;
|
|
185
|
+
background: #f3f4f6;
|
|
186
|
+
color: #374151;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.delete-btn:hover {
|
|
190
|
+
color: #dc2626;
|
|
191
|
+
border-color: #fca5a5;
|
|
192
|
+
background: #fef2f2;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.delete-icon {
|
|
196
|
+
width: 14px;
|
|
197
|
+
height: 14px;
|
|
198
|
+
}
|
|
199
|
+
</style>
|