@fe-free/core 2.0.0 → 2.0.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # @fe-free/core
2
2
 
3
+ ## 2.0.1
4
+
5
+ ### Patch Changes
6
+
7
+ - feat: editor mention
8
+ - @fe-free/tool@2.0.1
9
+
3
10
  ## 2.0.0
4
11
 
5
12
  ### Major Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fe-free/core",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "description": "",
5
5
  "main": "./src/index.ts",
6
6
  "author": "",
@@ -38,7 +38,7 @@
38
38
  "remark-gfm": "^4.0.1",
39
39
  "vanilla-jsoneditor": "^0.23.1",
40
40
  "zustand": "^4.5.4",
41
- "@fe-free/tool": "2.0.0"
41
+ "@fe-free/tool": "2.0.1"
42
42
  },
43
43
  "peerDependencies": {
44
44
  "@ant-design/pro-components": "^2.8.7",
@@ -0,0 +1,31 @@
1
+ import { EditorMention } from '@fe-free/core';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
+
4
+ const meta: Meta<typeof EditorMention> = {
5
+ title: '@fe-free/core/EditorMention',
6
+ component: EditorMention,
7
+ tags: ['autodocs'],
8
+ };
9
+
10
+ export default meta;
11
+
12
+ type Story = StoryObj<typeof EditorMention>;
13
+
14
+ export const Default: Story = {
15
+ args: {
16
+ value: `
17
+ adfa
18
+ asf <a>asdf</a>
19
+ asfa
20
+ `,
21
+ items: [
22
+ {
23
+ label: '用户',
24
+ options: [
25
+ { value: '1', label: '张三' },
26
+ { value: '2', label: '李四' },
27
+ ],
28
+ },
29
+ ],
30
+ },
31
+ };
File without changes
@@ -0,0 +1,359 @@
1
+ import { useMemoizedFn, useUpdateEffect } from 'ahooks';
2
+ import classNames from 'classnames';
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
+ import { escapeHTML } from './helper';
5
+
6
+ interface EditorMentionItem {
7
+ value: string;
8
+ label: string;
9
+ }
10
+
11
+ interface EditorMentionProps {
12
+ value?: string;
13
+ onChange?: (value: string) => void;
14
+ /** 默认前缀 */
15
+ prefix?: string[];
16
+ /** 下拉列表 */
17
+ items?: {
18
+ label: string;
19
+ options: EditorMentionItem[];
20
+ }[];
21
+ /** 渲染下拉列表 */
22
+ renderItem?: (props: { item: EditorMentionItem; index: number }) => React.ReactNode;
23
+ renderTagHTML?: (props: { item: EditorMentionItem }) => string;
24
+ placeholder?: string;
25
+ resizeHeight?: boolean;
26
+ defaultHeight?: number;
27
+ }
28
+
29
+ const emptyArr = [];
30
+ const tagClassName = 'cl-editor-mention-tag';
31
+ const defaultPrefix = ['/'];
32
+
33
+ function defaultRenderItem({ item }) {
34
+ return <div className="py-1 text-sm pl-4 pr-2">{item.label}</div>;
35
+ }
36
+
37
+ function defaultRenderTagHTML({ item }) {
38
+ return `<span class="text-sm h-[1em] rounded px-1 bg-gray-100">${item.label}</span>`;
39
+ }
40
+
41
+ function createMentionTag({ item, renderTagHTML }) {
42
+ const span = document.createElement('span');
43
+ span.className = tagClassName;
44
+ span.setAttribute('data-value', item.value);
45
+ span.contentEditable = 'false';
46
+ span.innerHTML = renderTagHTML({ item });
47
+
48
+ return span;
49
+ }
50
+
51
+ function insertMention({ item, renderTagHTML }) {
52
+ const selection = window.getSelection();
53
+
54
+ if (selection && selection.rangeCount > 0) {
55
+ const range = selection.getRangeAt(0);
56
+
57
+ // 删除触发字符
58
+ try {
59
+ range.setStart(range.endContainer, range.endOffset - 1);
60
+ range.deleteContents();
61
+ } catch (e) {
62
+ console.error('Error setting range:', e);
63
+ }
64
+
65
+ // 创建变量标签元素
66
+ const span = createMentionTag({ item, renderTagHTML });
67
+
68
+ // 插入变量标签
69
+ range.insertNode(span);
70
+
71
+ // 将光标移动到变量标签后面
72
+ range.setStartAfter(span);
73
+ range.setEndAfter(span);
74
+ selection.removeAllRanges();
75
+ selection.addRange(range);
76
+ }
77
+ }
78
+
79
+ function useDropdown({ items, renderItem, onSelect, editorRef }) {
80
+ const [show, setShow] = useState(false);
81
+
82
+ const [position, setPosition] = useState({ top: 0, left: 0 });
83
+
84
+ const handleSelect = (item: { value: string; label: string }) => {
85
+ setShow(false);
86
+ onSelect(item);
87
+ };
88
+
89
+ // 当下拉框显示时,点击外部区域关闭下拉框
90
+ useEffect(() => {
91
+ const handleClickOutside = (e: MouseEvent) => {
92
+ if (editorRef.current && !editorRef.current.contains(e.target as Node)) {
93
+ setShow(false);
94
+ }
95
+ };
96
+
97
+ document.addEventListener('mousedown', handleClickOutside);
98
+ return () => {
99
+ document.removeEventListener('mousedown', handleClickOutside);
100
+ };
101
+ }, []);
102
+
103
+ const node = show && (
104
+ <div
105
+ // 阻止下拉菜单点击事件冒泡
106
+ onClick={(e) => e.stopPropagation()}
107
+ className="shadow-md rounded p-1 z-10 max-h-[250px] overflow-y-auto bg-white absolute max-w-[250px]"
108
+ style={{
109
+ top: `${position.top}px`,
110
+ left: `${position.left}px`,
111
+ }}
112
+ >
113
+ {items.length === 0 && <div className="text-desc text-sm p-2">暂无数据</div>}
114
+ {items.map((group, i) => (
115
+ <div key={group.label}>
116
+ <div className="text-desc text-sm p-2">{group.label}</div>
117
+ <div>
118
+ {group.options.map((item) => (
119
+ <div
120
+ key={item.value}
121
+ onMouseDown={(e) => {
122
+ e.preventDefault(); // 防止失去焦点
123
+ handleSelect(item);
124
+ }}
125
+ className="cursor-pointer hover:bg-gray-100 rounded"
126
+ >
127
+ {renderItem({ item, index: i })}
128
+ </div>
129
+ ))}
130
+ </div>
131
+ </div>
132
+ ))}
133
+ </div>
134
+ );
135
+
136
+ return { node, setPosition, setShow };
137
+ }
138
+
139
+ function useContent({ value, items, renderTagHTML }) {
140
+ const initialContent = useMemo(() => {
141
+ let html = escapeHTML(value || '');
142
+ // 替换变量
143
+ items.forEach((group) => {
144
+ group.options.forEach((item) => {
145
+ html = html.replace(
146
+ new RegExp(escapeHTML(item.value), 'g'),
147
+ createMentionTag({ item, renderTagHTML }).outerHTML,
148
+ );
149
+ });
150
+ });
151
+
152
+ // 处理换行
153
+ html = html.replace(/\n/g, '<br>');
154
+
155
+ return html;
156
+ }, [value, items, renderTagHTML]);
157
+
158
+ const [content, setContent] = useState(initialContent);
159
+
160
+ return {
161
+ setContent,
162
+ content,
163
+ };
164
+ }
165
+
166
+ function useChange({ content, editorRef, onChange }) {
167
+ const ref = useRef<HTMLDivElement>(null);
168
+
169
+ // 获取最终输出文本(将标签转换为变量格式)
170
+ const getOutputValue = useCallback(() => {
171
+ if (!editorRef.current || !ref.current) {
172
+ return '';
173
+ }
174
+
175
+ ref.current.innerHTML = editorRef.current.innerHTML;
176
+
177
+ // 查找所有变量标签并替换为变量格式
178
+ const tags = ref.current.querySelectorAll(`.${tagClassName}`);
179
+ tags.forEach((tag) => {
180
+ const value = tag.getAttribute('data-value');
181
+ const textNode = document.createTextNode(value!);
182
+ tag.parentNode?.replaceChild(textNode, tag);
183
+ });
184
+
185
+ return ref.current.innerText || '';
186
+ }, [editorRef]);
187
+
188
+ const change = useMemoizedFn((arg) => {
189
+ onChange?.(arg);
190
+ });
191
+
192
+ // 问题:
193
+ // - 之前使用 useEffect,首次 effect 的时候上层是 hidden,然后 innerText 获取到换行符丢了(不知道为啥),onChange 回去的时候换行符就没了。
194
+ // - 之后如果点了保存,则换行符就丢了。
195
+ // 解法:onChange 只有首次 effect 和后续改动文本。前缀换成 useUpdateEffect,后者的时候非 hidden 了,所以没有问题。
196
+ useUpdateEffect(() => {
197
+ change(getOutputValue());
198
+ }, [content, getOutputValue, change]);
199
+
200
+ const node = (
201
+ <div
202
+ ref={ref}
203
+ className="absolute top-[-10px] left-[-10px] w-[1px] h-[1px] overflow-auto"
204
+ contentEditable="true"
205
+ />
206
+ );
207
+
208
+ return { node };
209
+ }
210
+
211
+ const EditorMention = ({
212
+ value,
213
+ onChange,
214
+ prefix = defaultPrefix,
215
+ items = emptyArr,
216
+ renderItem = defaultRenderItem,
217
+ placeholder = '输入 / 插入提示内容',
218
+ renderTagHTML = defaultRenderTagHTML,
219
+ resizeHeight,
220
+ defaultHeight,
221
+ }: EditorMentionProps) => {
222
+ const editorRef = useRef<HTMLDivElement>(null);
223
+
224
+ const { content, setContent } = useContent({ value, items, renderTagHTML });
225
+
226
+ useEffect(() => {
227
+ console.log('EditorMention 为非受控组件,请注意');
228
+ if (!editorRef.current) {
229
+ return;
230
+ }
231
+
232
+ editorRef.current.innerHTML = content;
233
+ }, []);
234
+
235
+ const { node: tempNode } = useChange({
236
+ content,
237
+ editorRef,
238
+ onChange,
239
+ });
240
+
241
+ // 处理变量选择
242
+ const handleSelectVariable = useCallback(
243
+ (item: { value: string; label: string }) => {
244
+ if (!editorRef.current) {
245
+ return;
246
+ }
247
+
248
+ // 确保编辑器获得焦点
249
+ editorRef.current.focus();
250
+
251
+ // 插入 mention
252
+ insertMention({ item, renderTagHTML });
253
+
254
+ // 更新内容
255
+ setContent(editorRef.current.innerHTML);
256
+
257
+ // 确保编辑器保持焦点
258
+ setTimeout(() => {
259
+ editorRef.current?.focus();
260
+ }, 0);
261
+ },
262
+ [renderTagHTML, setContent],
263
+ );
264
+
265
+ const {
266
+ node: dropdownNode,
267
+ setShow: setDropdownShow,
268
+ setPosition: setDropdownPosition,
269
+ } = useDropdown({
270
+ items,
271
+ renderItem,
272
+ onSelect: handleSelectVariable,
273
+ editorRef,
274
+ });
275
+
276
+ // 处理输入事件
277
+ const handleInput = useCallback(
278
+ (e) => {
279
+ const text = e.currentTarget.innerHTML;
280
+ setContent(text);
281
+
282
+ const selection = window.getSelection();
283
+ if (selection && selection.rangeCount > 0) {
284
+ const range = selection.getRangeAt(0);
285
+
286
+ const preCaretRange = range.cloneRange();
287
+ preCaretRange.selectNodeContents(e.currentTarget);
288
+ preCaretRange.setEnd(range.endContainer, range.endOffset);
289
+ const textBeforeCaret = preCaretRange.toString();
290
+
291
+ // 检查是否输入了 '\'
292
+ const isMatch = prefix.some((p) => textBeforeCaret.endsWith(p));
293
+ if (isMatch) {
294
+ // 计算下拉框位置
295
+ const rect = range.getBoundingClientRect();
296
+ const editorRect = editorRef.current?.getBoundingClientRect();
297
+
298
+ if (editorRect) {
299
+ setDropdownPosition({
300
+ top: rect.bottom - editorRect.top,
301
+ left: rect.left - editorRect.left,
302
+ });
303
+ setDropdownShow(true);
304
+ }
305
+ } else {
306
+ setDropdownShow(false);
307
+ }
308
+ }
309
+ },
310
+ [prefix, setContent, setDropdownPosition, setDropdownShow],
311
+ );
312
+
313
+ // 处理键盘事件
314
+ const handleKeyDown = useCallback(
315
+ (e) => {
316
+ // 如果按下 Escape 键,关闭下拉框
317
+ if (e.key === 'Escape') {
318
+ setDropdownShow(false);
319
+ }
320
+ },
321
+ [setDropdownShow],
322
+ );
323
+
324
+ const isEmpty = !content || content === '<br>';
325
+
326
+ return (
327
+ <div className="c-editor-mention relative h-full flex">
328
+ <div
329
+ ref={editorRef}
330
+ contentEditable="true"
331
+ suppressContentEditableWarning={true}
332
+ onInput={handleInput}
333
+ onKeyDown={handleKeyDown}
334
+ className={classNames(
335
+ 'flex-1 w-full p-2 c-border rounded outline-none overflow-y-auto focus:border-primary',
336
+ resizeHeight && 'resize-y',
337
+ )}
338
+ style={{
339
+ height: defaultHeight,
340
+ }}
341
+ />
342
+ {placeholder && isEmpty && (
343
+ <div
344
+ className="absolute top-0 left-0 text-gray-500 p-2 cursor-text"
345
+ onClick={() => {
346
+ editorRef.current?.focus();
347
+ }}
348
+ >
349
+ {placeholder}
350
+ </div>
351
+ )}
352
+ {dropdownNode}
353
+ {tempNode}
354
+ </div>
355
+ );
356
+ };
357
+
358
+ export { EditorMention };
359
+ export type { EditorMentionProps };
package/src/index.ts CHANGED
@@ -9,6 +9,8 @@ export { EditorJSON } from './editor_json';
9
9
  export type { EditorJSONProps } from './editor_json';
10
10
  export { EditorLogs } from './editor_logs';
11
11
  export type { EditorLogsProps } from './editor_logs';
12
+ export { EditorMention } from './editor_mention';
13
+ export type { EditorMentionProps } from './editor_mention';
12
14
  export {
13
15
  ProFormEditor,
14
16
  ProFormJSON,