n20-project-component 1.0.3 → 1.0.6

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.
@@ -1,318 +1,318 @@
1
- <template>
2
- <div
3
- class="n20-floating-toolbar-wrapper"
4
- ref="wrapper"
5
- @mouseup="handleMouseUp"
6
- >
7
- <!-- 包裹的内容区域 -->
8
- <slot></slot>
9
-
10
- <!-- 浮现工具条 -->
11
- <transition name="n20-toolbar-fade">
12
- <div
13
- v-if="visible"
14
- ref="toolbar"
15
- class="n20-floating-toolbar"
16
- :style="toolbarStyle"
17
- @mousedown.prevent
18
- >
19
- <div class="n20-floating-toolbar__arrow"></div>
20
- <div class="n20-floating-toolbar__body">
21
- <button
22
- v-for="(btn, idx) in actions"
23
- :key="idx"
24
- class="n20-floating-toolbar__btn"
25
- :title="btn.label"
26
- @click.stop="handleAction(btn, idx)"
27
- >
28
- <i v-if="btn.icon" :class="btn.icon"></i>
29
- <span v-if="btn.label && showLabel">{{ btn.label }}</span>
30
- </button>
31
- <!-- 允许通过 slot 自定义工具栏内容 -->
32
- <slot name="toolbar" :selectedText="selectedText"></slot>
33
- </div>
34
- </div>
35
- </transition>
36
- </div>
37
- </template>
38
-
39
- <script>
40
- /**
41
- * N20FloatingToolbar — 选中浮现工具栏
42
- *
43
- * 包裹内容区域,用户选中文字后在选区上方浮现操作按钮条。
44
- * 鼠标拖选文字松开后,选区上方浮现深色工具条,
45
- * 点击按钮触发事件,点其他地方消失。
46
- *
47
- * @example
48
- * <N20FloatingToolbar
49
- * :actions="[
50
- * { label: '复制', icon: 'el-icon-document-copy' },
51
- * { label: '搜索', icon: 'el-icon-search' },
52
- * { label: '高亮', icon: 'el-icon-edit' },
53
- * ]"
54
- * @action="handleToolbarAction"
55
- * >
56
- * <p>这里是一段可以被选中的文本内容...</p>
57
- * </N20FloatingToolbar>
58
- */
59
- export default {
60
- name: 'N20FloatingToolbar',
61
- props: {
62
- /**
63
- * 工具栏按钮列表
64
- * 每项 { label: string, icon?: string, command?: string }
65
- */
66
- actions: {
67
- type: Array,
68
- default: () => [
69
- { label: '复制', icon: 'el-icon-document-copy', command: 'copy' },
70
- { label: '搜索', icon: 'el-icon-search', command: 'search' },
71
- { label: '高亮', icon: 'el-icon-edit', command: 'highlight' },
72
- ]
73
- },
74
- /** 是否在按钮中显示文字标签 */
75
- showLabel: {
76
- type: Boolean,
77
- default: true
78
- },
79
- /** 工具栏与选区的垂直间距(px) */
80
- offset: {
81
- type: Number,
82
- default: 8
83
- },
84
- /** 是否禁用 */
85
- disabled: {
86
- type: Boolean,
87
- default: false
88
- },
89
- /** 选中最少字符数才触发 */
90
- minLength: {
91
- type: Number,
92
- default: 1
93
- }
94
- },
95
- data() {
96
- return {
97
- visible: false,
98
- selectedText: '',
99
- toolbarStyle: {
100
- top: '0px',
101
- left: '0px'
102
- }
103
- }
104
- },
105
- mounted() {
106
- // 监听全局点击 → 隐藏工具栏
107
- this._onDocClick = (e) => {
108
- if (this.visible && this.$refs.toolbar && !this.$refs.toolbar.contains(e.target)) {
109
- this.hide()
110
- }
111
- }
112
- // 监听滚动 → 隐藏工具栏
113
- this._onScroll = () => {
114
- if (this.visible) {
115
- this.hide()
116
- }
117
- }
118
- document.addEventListener('mousedown', this._onDocClick, true)
119
- window.addEventListener('scroll', this._onScroll, true)
120
- },
121
- beforeDestroy() {
122
- document.removeEventListener('mousedown', this._onDocClick, true)
123
- window.removeEventListener('scroll', this._onScroll, true)
124
- },
125
- methods: {
126
- /** 鼠标松开时判断是否有选区 */
127
- handleMouseUp() {
128
- if (this.disabled) return
129
-
130
- // 延迟一帧让浏览器完成选区更新
131
- this.$nextTick(() => {
132
- setTimeout(() => {
133
- this.checkSelection()
134
- }, 0)
135
- })
136
- },
137
-
138
- /** 检测当前选区 */
139
- checkSelection() {
140
- const selection = window.getSelection()
141
- if (!selection || selection.isCollapsed || !selection.toString().trim()) {
142
- this.hide()
143
- return
144
- }
145
-
146
- const text = selection.toString().trim()
147
- if (text.length < this.minLength) {
148
- this.hide()
149
- return
150
- }
151
-
152
- // 确保选区在 wrapper 内
153
- const wrapper = this.$refs.wrapper
154
- if (!wrapper) return
155
-
156
- const range = selection.getRangeAt(0)
157
- if (!wrapper.contains(range.commonAncestorContainer)) {
158
- this.hide()
159
- return
160
- }
161
-
162
- this.selectedText = text
163
- this.positionToolbar(range)
164
- this.visible = true
165
- },
166
-
167
- /** 根据选区 Range 定位工具栏 */
168
- positionToolbar(range) {
169
- const rect = range.getBoundingClientRect()
170
- const wrapperRect = this.$refs.wrapper.getBoundingClientRect()
171
-
172
- // 工具栏居中于选区上方
173
- const left = rect.left + rect.width / 2 - wrapperRect.left
174
- const top = rect.top - wrapperRect.top - this.offset
175
-
176
- this.toolbarStyle = {
177
- left: `${left}px`,
178
- top: `${top}px`
179
- }
180
- },
181
-
182
- /** 点击按钮 */
183
- handleAction(btn, idx) {
184
- this.$emit('action', {
185
- command: btn.command || btn.label,
186
- label: btn.label,
187
- index: idx,
188
- text: this.selectedText,
189
- })
190
-
191
- // 内置 copy 行为
192
- if (btn.command === 'copy') {
193
- this.doCopy(this.selectedText)
194
- }
195
-
196
- this.hide()
197
- },
198
-
199
- /** 复制文本到剪贴板 */
200
- async doCopy(text) {
201
- try {
202
- if (navigator.clipboard && window.isSecureContext) {
203
- await navigator.clipboard.writeText(text)
204
- } else {
205
- const textarea = document.createElement('textarea')
206
- textarea.value = text
207
- textarea.style.position = 'fixed'
208
- textarea.style.left = '-9999px'
209
- document.body.appendChild(textarea)
210
- textarea.select()
211
- document.execCommand('copy')
212
- document.body.removeChild(textarea)
213
- }
214
- this.$message && this.$message.success('已复制')
215
- } catch (err) {
216
- this.$message && this.$message.error('复制失败')
217
- }
218
- },
219
-
220
- /** 隐藏工具栏 */
221
- hide() {
222
- this.visible = false
223
- this.selectedText = ''
224
- },
225
-
226
- /** 外部可调用:手动显示 */
227
- show() {
228
- this.checkSelection()
229
- }
230
- }
231
- }
232
- </script>
233
-
234
- <style lang="scss" scoped>
235
- .n20-floating-toolbar-wrapper {
236
- position: relative;
237
- display: inline-block;
238
- width: 100%;
239
- }
240
-
241
- .n20-floating-toolbar {
242
- position: absolute;
243
- z-index: 9999;
244
- transform: translateX(-50%) translateY(-100%);
245
- pointer-events: auto;
246
-
247
- // 箭头
248
- &__arrow {
249
- position: absolute;
250
- left: 50%;
251
- bottom: -5px;
252
- transform: translateX(-50%) rotate(45deg);
253
- width: 10px;
254
- height: 10px;
255
- background: #1f2937;
256
- border-radius: 0 0 2px 0;
257
- }
258
-
259
- // 主体
260
- &__body {
261
- display: inline-flex;
262
- align-items: center;
263
- gap: 2px;
264
- padding: 4px 6px;
265
- background: #1f2937;
266
- border-radius: 6px;
267
- box-shadow: 0 4px 16px rgba(0, 0, 0, 0.24), 0 0 0 1px rgba(255, 255, 255, 0.06) inset;
268
- white-space: nowrap;
269
- }
270
-
271
- // 按钮
272
- &__btn {
273
- display: inline-flex;
274
- align-items: center;
275
- gap: 4px;
276
- padding: 5px 10px;
277
- border: none;
278
- border-radius: 4px;
279
- background: transparent;
280
- color: #e5e7eb;
281
- font-size: 13px;
282
- line-height: 1;
283
- cursor: pointer;
284
- transition: background 0.15s ease, color 0.15s ease;
285
- outline: none;
286
-
287
- i {
288
- font-size: 14px;
289
- }
290
-
291
- &:hover {
292
- background: rgba(255, 255, 255, 0.12);
293
- color: #fff;
294
- }
295
-
296
- &:active {
297
- background: rgba(255, 255, 255, 0.18);
298
- }
299
-
300
- & + & {
301
- margin-left: 1px;
302
- }
303
- }
304
- }
305
-
306
- // 出现动画
307
- .n20-toolbar-fade-enter-active {
308
- transition: opacity 0.18s ease, transform 0.18s ease;
309
- }
310
- .n20-toolbar-fade-leave-active {
311
- transition: opacity 0.12s ease, transform 0.12s ease;
312
- }
313
- .n20-toolbar-fade-enter,
314
- .n20-toolbar-fade-leave-to {
315
- opacity: 0;
316
- transform: translateX(-50%) translateY(-100%) scale(0.92);
317
- }
318
- </style>
1
+ <template>
2
+ <div
3
+ class="n20-floating-toolbar-wrapper"
4
+ ref="wrapper"
5
+ @mouseup="handleMouseUp"
6
+ >
7
+ <!-- 包裹的内容区域 -->
8
+ <slot></slot>
9
+
10
+ <!-- 浮现工具条 -->
11
+ <transition name="n20-toolbar-fade">
12
+ <div
13
+ v-if="visible"
14
+ ref="toolbar"
15
+ class="n20-floating-toolbar"
16
+ :style="toolbarStyle"
17
+ @mousedown.prevent
18
+ >
19
+ <div class="n20-floating-toolbar__arrow"></div>
20
+ <div class="n20-floating-toolbar__body">
21
+ <button
22
+ v-for="(btn, idx) in actions"
23
+ :key="idx"
24
+ class="n20-floating-toolbar__btn"
25
+ :title="btn.label"
26
+ @click.stop="handleAction(btn, idx)"
27
+ >
28
+ <i v-if="btn.icon" :class="btn.icon"></i>
29
+ <span v-if="btn.label && showLabel">{{ btn.label }}</span>
30
+ </button>
31
+ <!-- 允许通过 slot 自定义工具栏内容 -->
32
+ <slot name="toolbar" :selectedText="selectedText"></slot>
33
+ </div>
34
+ </div>
35
+ </transition>
36
+ </div>
37
+ </template>
38
+
39
+ <script>
40
+ /**
41
+ * N20FloatingToolbar — 选中浮现工具栏
42
+ *
43
+ * 包裹内容区域,用户选中文字后在选区上方浮现操作按钮条。
44
+ * 鼠标拖选文字松开后,选区上方浮现深色工具条,
45
+ * 点击按钮触发事件,点其他地方消失。
46
+ *
47
+ * @example
48
+ * <N20FloatingToolbar
49
+ * :actions="[
50
+ * { label: '复制', icon: 'el-icon-document-copy' },
51
+ * { label: '搜索', icon: 'el-icon-search' },
52
+ * { label: '高亮', icon: 'el-icon-edit' },
53
+ * ]"
54
+ * @action="handleToolbarAction"
55
+ * >
56
+ * <p>这里是一段可以被选中的文本内容...</p>
57
+ * </N20FloatingToolbar>
58
+ */
59
+ export default {
60
+ name: 'N20FloatingToolbar',
61
+ props: {
62
+ /**
63
+ * 工具栏按钮列表
64
+ * 每项 { label: string, icon?: string, command?: string }
65
+ */
66
+ actions: {
67
+ type: Array,
68
+ default: () => [
69
+ { label: '复制', icon: 'el-icon-document-copy', command: 'copy' },
70
+ { label: '搜索', icon: 'el-icon-search', command: 'search' },
71
+ { label: '高亮', icon: 'el-icon-edit', command: 'highlight' },
72
+ ]
73
+ },
74
+ /** 是否在按钮中显示文字标签 */
75
+ showLabel: {
76
+ type: Boolean,
77
+ default: true
78
+ },
79
+ /** 工具栏与选区的垂直间距(px) */
80
+ offset: {
81
+ type: Number,
82
+ default: 8
83
+ },
84
+ /** 是否禁用 */
85
+ disabled: {
86
+ type: Boolean,
87
+ default: false
88
+ },
89
+ /** 选中最少字符数才触发 */
90
+ minLength: {
91
+ type: Number,
92
+ default: 1
93
+ }
94
+ },
95
+ data() {
96
+ return {
97
+ visible: false,
98
+ selectedText: '',
99
+ toolbarStyle: {
100
+ top: '0px',
101
+ left: '0px'
102
+ }
103
+ }
104
+ },
105
+ mounted() {
106
+ // 监听全局点击 → 隐藏工具栏
107
+ this._onDocClick = (e) => {
108
+ if (this.visible && this.$refs.toolbar && !this.$refs.toolbar.contains(e.target)) {
109
+ this.hide()
110
+ }
111
+ }
112
+ // 监听滚动 → 隐藏工具栏
113
+ this._onScroll = () => {
114
+ if (this.visible) {
115
+ this.hide()
116
+ }
117
+ }
118
+ document.addEventListener('mousedown', this._onDocClick, true)
119
+ window.addEventListener('scroll', this._onScroll, true)
120
+ },
121
+ beforeDestroy() {
122
+ document.removeEventListener('mousedown', this._onDocClick, true)
123
+ window.removeEventListener('scroll', this._onScroll, true)
124
+ },
125
+ methods: {
126
+ /** 鼠标松开时判断是否有选区 */
127
+ handleMouseUp() {
128
+ if (this.disabled) return
129
+
130
+ // 延迟一帧让浏览器完成选区更新
131
+ this.$nextTick(() => {
132
+ setTimeout(() => {
133
+ this.checkSelection()
134
+ }, 0)
135
+ })
136
+ },
137
+
138
+ /** 检测当前选区 */
139
+ checkSelection() {
140
+ const selection = window.getSelection()
141
+ if (!selection || selection.isCollapsed || !selection.toString().trim()) {
142
+ this.hide()
143
+ return
144
+ }
145
+
146
+ const text = selection.toString().trim()
147
+ if (text.length < this.minLength) {
148
+ this.hide()
149
+ return
150
+ }
151
+
152
+ // 确保选区在 wrapper 内
153
+ const wrapper = this.$refs.wrapper
154
+ if (!wrapper) return
155
+
156
+ const range = selection.getRangeAt(0)
157
+ if (!wrapper.contains(range.commonAncestorContainer)) {
158
+ this.hide()
159
+ return
160
+ }
161
+
162
+ this.selectedText = text
163
+ this.positionToolbar(range)
164
+ this.visible = true
165
+ },
166
+
167
+ /** 根据选区 Range 定位工具栏 */
168
+ positionToolbar(range) {
169
+ const rect = range.getBoundingClientRect()
170
+ const wrapperRect = this.$refs.wrapper.getBoundingClientRect()
171
+
172
+ // 工具栏居中于选区上方
173
+ const left = rect.left + rect.width / 2 - wrapperRect.left
174
+ const top = rect.top - wrapperRect.top - this.offset
175
+
176
+ this.toolbarStyle = {
177
+ left: `${left}px`,
178
+ top: `${top}px`
179
+ }
180
+ },
181
+
182
+ /** 点击按钮 */
183
+ handleAction(btn, idx) {
184
+ this.$emit('action', {
185
+ command: btn.command || btn.label,
186
+ label: btn.label,
187
+ index: idx,
188
+ text: this.selectedText,
189
+ })
190
+
191
+ // 内置 copy 行为
192
+ if (btn.command === 'copy') {
193
+ this.doCopy(this.selectedText)
194
+ }
195
+
196
+ this.hide()
197
+ },
198
+
199
+ /** 复制文本到剪贴板 */
200
+ async doCopy(text) {
201
+ try {
202
+ if (navigator.clipboard && window.isSecureContext) {
203
+ await navigator.clipboard.writeText(text)
204
+ } else {
205
+ const textarea = document.createElement('textarea')
206
+ textarea.value = text
207
+ textarea.style.position = 'fixed'
208
+ textarea.style.left = '-9999px'
209
+ document.body.appendChild(textarea)
210
+ textarea.select()
211
+ document.execCommand('copy')
212
+ document.body.removeChild(textarea)
213
+ }
214
+ this.$message && this.$message.success('已复制')
215
+ } catch (err) {
216
+ this.$message && this.$message.error('复制失败')
217
+ }
218
+ },
219
+
220
+ /** 隐藏工具栏 */
221
+ hide() {
222
+ this.visible = false
223
+ this.selectedText = ''
224
+ },
225
+
226
+ /** 外部可调用:手动显示 */
227
+ show() {
228
+ this.checkSelection()
229
+ }
230
+ }
231
+ }
232
+ </script>
233
+
234
+ <style lang="scss" scoped>
235
+ .n20-floating-toolbar-wrapper {
236
+ position: relative;
237
+ display: inline-block;
238
+ width: 100%;
239
+ }
240
+
241
+ .n20-floating-toolbar {
242
+ position: absolute;
243
+ z-index: 9999;
244
+ transform: translateX(-50%) translateY(-100%);
245
+ pointer-events: auto;
246
+
247
+ // 箭头
248
+ &__arrow {
249
+ position: absolute;
250
+ left: 50%;
251
+ bottom: -5px;
252
+ transform: translateX(-50%) rotate(45deg);
253
+ width: 10px;
254
+ height: 10px;
255
+ background: #1f2937;
256
+ border-radius: 0 0 2px 0;
257
+ }
258
+
259
+ // 主体
260
+ &__body {
261
+ display: inline-flex;
262
+ align-items: center;
263
+ gap: 2px;
264
+ padding: 4px 6px;
265
+ background: #1f2937;
266
+ border-radius: 6px;
267
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.24), 0 0 0 1px rgba(255, 255, 255, 0.06) inset;
268
+ white-space: nowrap;
269
+ }
270
+
271
+ // 按钮
272
+ &__btn {
273
+ display: inline-flex;
274
+ align-items: center;
275
+ gap: 4px;
276
+ padding: 5px 10px;
277
+ border: none;
278
+ border-radius: 4px;
279
+ background: transparent;
280
+ color: #e5e7eb;
281
+ font-size: 13px;
282
+ line-height: 1;
283
+ cursor: pointer;
284
+ transition: background 0.15s ease, color 0.15s ease;
285
+ outline: none;
286
+
287
+ i {
288
+ font-size: 14px;
289
+ }
290
+
291
+ &:hover {
292
+ background: rgba(255, 255, 255, 0.12);
293
+ color: #fff;
294
+ }
295
+
296
+ &:active {
297
+ background: rgba(255, 255, 255, 0.18);
298
+ }
299
+
300
+ & + & {
301
+ margin-left: 1px;
302
+ }
303
+ }
304
+ }
305
+
306
+ // 出现动画
307
+ .n20-toolbar-fade-enter-active {
308
+ transition: opacity 0.18s ease, transform 0.18s ease;
309
+ }
310
+ .n20-toolbar-fade-leave-active {
311
+ transition: opacity 0.12s ease, transform 0.12s ease;
312
+ }
313
+ .n20-toolbar-fade-enter,
314
+ .n20-toolbar-fade-leave-to {
315
+ opacity: 0;
316
+ transform: translateX(-50%) translateY(-100%) scale(0.92);
317
+ }
318
+ </style>