Skip to content

Commit fabec61

Browse files
authored
feat: add PrefixElement (#117)
Co-authored-by: stream-pipe <[email protected]>
1 parent af8d441 commit fabec61

File tree

7 files changed

+257
-1
lines changed

7 files changed

+257
-1
lines changed
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@coze-editor/react-components",
5+
"comment": "add PrefixElement",
6+
"type": "minor"
7+
}
8+
],
9+
"packageName": "@coze-editor/react-components",
10+
"email": "[email protected]"
11+
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createRoot } from 'react-dom/client';
2-
import Page from './pages/code';
2+
import Page from './pages/prefix';
33
import './index.css';
44

55
createRoot(document.getElementById('app')!).render(<Page />);
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { Extension } from '@codemirror/state';
2+
import { EditorView } from '@codemirror/view';
3+
import preset, { EditorAPI } from '@coze-editor/editor/preset-universal';
4+
import { EditorProvider, Placeholder, PrefixElement, Renderer } from '@coze-editor/editor/react'
5+
import { Radio, RadioGroup, Tag } from '@douyinfe/semi-ui';
6+
import { useMemo, useRef, useState } from 'react';
7+
8+
const defaultValue = ''
9+
const extensions: Extension[] = [
10+
EditorView.theme({
11+
'&.cm-focused': {
12+
outline: 'none',
13+
},
14+
'&': {
15+
border: '1px dotted #212121',
16+
},
17+
})
18+
];
19+
20+
function Page() {
21+
const editorRef = useRef<EditorAPI | null>(null)
22+
const [mode, setMode] = useState('code');
23+
24+
const modeText = useMemo(() => {
25+
const map: Record<string, string> = {
26+
code: '编程',
27+
writing: '写作',
28+
}
29+
30+
return map[mode]
31+
}, [mode]);
32+
33+
return <div
34+
style={{
35+
display: 'flex',
36+
flexDirection: 'column',
37+
justifyContent: 'center',
38+
width: 500,
39+
margin: '100px auto',
40+
}}
41+
>
42+
<div
43+
style={{
44+
display: 'flex',
45+
justifyContent: 'center',
46+
marginBottom: 20,
47+
}}
48+
>
49+
<RadioGroup
50+
type='button'
51+
buttonSize='small'
52+
defaultValue={mode}
53+
onChange={e => {
54+
setMode(e.target.value)
55+
editorRef.current?.focus();
56+
}}
57+
>
58+
<Radio value={'code'}>编程</Radio>
59+
<Radio value={'writing'}>写作</Radio>
60+
</RadioGroup>
61+
</div>
62+
63+
<EditorProvider>
64+
<Renderer
65+
plugins={preset}
66+
defaultValue={defaultValue}
67+
options={{
68+
minHeight: 200,
69+
lineWrapping: true,
70+
}}
71+
extensions={extensions}
72+
didMount={(editor: EditorAPI) => {
73+
editorRef.current = editor
74+
}}
75+
/>
76+
77+
<PrefixElement
78+
deletable
79+
onDelete={() => setMode('')}
80+
>
81+
{modeText ? (
82+
<Tag style={{marginRight: 5}} closable onClose={() => setMode('')}>{modeText}</Tag>
83+
) : null}
84+
</PrefixElement>
85+
86+
<Placeholder>
87+
{modeText ? <span style={{cursor: 'text'}}>输入你的{modeText}任务</span> : '默认提示'}
88+
</Placeholder>
89+
</EditorProvider>
90+
</div>
91+
}
92+
93+
export default Page

packages/text-editor/react-components/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,5 @@ export {
4545
EmbededLineViewSide,
4646
type EmbededLineViewRefProps,
4747
} from './embeded-line-view';
48+
49+
export { PrefixElement } from './prefix-element';
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Copyright (c) 2025 coze-dev
2+
// SPDX-License-Identifier: MIT
3+
4+
/// Basic rectangle type.
5+
interface Rect {
6+
readonly left: number;
7+
readonly right: number;
8+
readonly top: number;
9+
readonly bottom: number;
10+
}
11+
12+
let scratchRange: Range | null;
13+
14+
function textRange(node: Text, from: number, to = from) {
15+
const range = scratchRange || (scratchRange = document.createRange());
16+
range.setEnd(node, to);
17+
range.setStart(node, from);
18+
return range;
19+
}
20+
21+
function flattenRect(rect: Rect, left: boolean) {
22+
const x = left ? rect.left : rect.right;
23+
return { left: x, right: x, top: rect.top, bottom: rect.bottom };
24+
}
25+
26+
function clientRectsFor(dom: Node) {
27+
if (dom.nodeType == 3) {
28+
return textRange(dom as Text, 0, dom.nodeValue!.length).getClientRects();
29+
} else if (dom.nodeType == 1) {
30+
return (dom as HTMLElement).getClientRects();
31+
} else {
32+
return [] as unknown as DOMRectList;
33+
}
34+
}
35+
36+
export { flattenRect, clientRectsFor };
37+
38+
export type { Rect };
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { PrefixElement } from './react';
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { createPortal } from 'react-dom';
2+
import { type ReactNode, useLayoutEffect } from 'react';
3+
4+
import { useHTMLElement, useLatest } from '@coze-editor/react-hooks';
5+
import { useInjector } from '@coze-editor/react';
6+
import { Decoration, EditorView, keymap, WidgetType } from '@codemirror/view';
7+
8+
import { clientRectsFor, flattenRect } from './dom';
9+
10+
class PrefixElementWidget extends WidgetType {
11+
constructor(
12+
readonly content:
13+
| string
14+
| HTMLElement
15+
| ((view: EditorView) => HTMLElement),
16+
) {
17+
super();
18+
}
19+
20+
toDOM(view: EditorView) {
21+
const wrap = document.createElement('span');
22+
wrap.className = 'cm-prefix-element';
23+
// solved the cursor height problem(eg. cursor double height with two lines)
24+
wrap.style.cssText = 'height: 0;';
25+
wrap.appendChild(
26+
typeof this.content === 'string'
27+
? document.createTextNode(this.content)
28+
: typeof this.content === 'function'
29+
? this.content(view)
30+
: this.content,
31+
);
32+
if (typeof this.content === 'string') {
33+
wrap.setAttribute('aria-label', `prefix ${this.content}`);
34+
} else {
35+
wrap.setAttribute('aria-hidden', 'true');
36+
}
37+
return wrap;
38+
}
39+
40+
coordsAt(dom: HTMLElement) {
41+
const rects = dom.firstChild ? clientRectsFor(dom.firstChild) : [];
42+
if (!rects.length) {
43+
return null;
44+
}
45+
const style = window.getComputedStyle(dom.parentNode as HTMLElement);
46+
const rect = flattenRect(rects[0], style.direction != 'rtl');
47+
const lineHeight = parseInt(style.lineHeight);
48+
if (rect.bottom - rect.top > lineHeight * 1.5) {
49+
return {
50+
left: rect.left,
51+
right: rect.right,
52+
top: rect.top,
53+
bottom: rect.top + lineHeight,
54+
};
55+
}
56+
return rect;
57+
}
58+
59+
ignoreEvent() {
60+
return false;
61+
}
62+
}
63+
64+
interface PrefixElementProps {
65+
deletable?: boolean;
66+
onDelete?: () => void;
67+
children?: ReactNode;
68+
}
69+
70+
function PrefixElement({ deletable, onDelete, children }: PrefixElementProps) {
71+
const element = useHTMLElement('span');
72+
const latestElement = useLatest(element);
73+
const latestDeletable = useLatest(deletable);
74+
const latestOnDelete = useLatest(onDelete);
75+
const injector = useInjector();
76+
77+
useLayoutEffect(() => {
78+
function run(view: EditorView) {
79+
if (
80+
view.state.selection.main.empty &&
81+
view.state.selection.main.head === 0 &&
82+
latestDeletable.current === true &&
83+
typeof latestOnDelete.current === 'function'
84+
) {
85+
latestOnDelete.current();
86+
}
87+
88+
return false;
89+
}
90+
91+
return injector.inject([
92+
EditorView.decorations.of(
93+
Decoration.set([
94+
Decoration.widget({
95+
side: -100,
96+
block: false,
97+
widget: new PrefixElementWidget(() => latestElement.current),
98+
}).range(0),
99+
]),
100+
),
101+
keymap.of([
102+
{ key: 'Backspace', shift: run, run },
103+
{ key: 'Meta-Backspace', shift: run, run },
104+
]),
105+
]);
106+
}, []);
107+
108+
return createPortal(children, element);
109+
}
110+
111+
export { PrefixElement };

0 commit comments

Comments
 (0)