Initial TrueGrowth source import
This commit is contained in:
35
packages/react-text/src/custom-types.ts
Normal file
35
packages/react-text/src/custom-types.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { BaseEditor, BaseRange, Range, Element } from 'slate';
|
||||
import { ReactEditor, RenderElementProps } from 'slate-react';
|
||||
import { HistoryEditor } from 'slate-history';
|
||||
import { CustomElement, CustomText as PlaitCustomText } from '@plait/common';
|
||||
|
||||
// 扩展 CustomText 类型,添加缺失的文本属性
|
||||
export interface ExtendedCustomText extends PlaitCustomText {
|
||||
'font-family'?: string;
|
||||
'font-weight'?: string;
|
||||
'text-shadow'?: string;
|
||||
'text-gradient'?: string;
|
||||
'line-height'?: string;
|
||||
'letter-spacing'?: string;
|
||||
}
|
||||
|
||||
export type CustomEditor = BaseEditor &
|
||||
ReactEditor &
|
||||
HistoryEditor & {
|
||||
nodeToDecorations?: Map<Element, Range[]>;
|
||||
};
|
||||
|
||||
export type RenderElementPropsFor<T> = RenderElementProps & {
|
||||
element: T;
|
||||
};
|
||||
|
||||
declare module 'slate' {
|
||||
interface CustomTypes {
|
||||
Editor: CustomEditor;
|
||||
Element: CustomElement;
|
||||
Text: ExtendedCustomText;
|
||||
Range: BaseRange & {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
}
|
||||
3
packages/react-text/src/index.ts
Normal file
3
packages/react-text/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './text';
|
||||
export * from './custom-types';
|
||||
export { setSearchHighlightQuery } from './search-highlight';
|
||||
2
packages/react-text/src/plugins/index.ts
Normal file
2
packages/react-text/src/plugins/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './with-text';
|
||||
export * from './with-link';
|
||||
65
packages/react-text/src/plugins/with-link.tsx
Normal file
65
packages/react-text/src/plugins/with-link.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { CustomElement, LinkElement } from '@plait/common';
|
||||
import { CustomEditor, RenderElementPropsFor } from '../custom-types';
|
||||
import { isUrl, LinkEditor } from '@plait/text-plugins';
|
||||
|
||||
// Put this at the start and end of an inline component to work around this Chromium bug:
|
||||
// https://bugs.chromium.org/p/chromium/issues/detail?id=1249405
|
||||
export const InlineChromiumBugfix = () => (
|
||||
<span contentEditable={false} style={{ fontSize: 0 }}>
|
||||
{String.fromCodePoint(160) /* Non-breaking space */}
|
||||
</span>
|
||||
);
|
||||
|
||||
export const LinkComponent = ({
|
||||
attributes,
|
||||
children,
|
||||
element,
|
||||
}: RenderElementPropsFor<LinkElement>) => {
|
||||
return (
|
||||
<a
|
||||
{...attributes}
|
||||
style={{
|
||||
textDecoration: 'none',
|
||||
cursor: 'inherit',
|
||||
}}
|
||||
data-url={element.url}
|
||||
className="plait-board-link"
|
||||
>
|
||||
<InlineChromiumBugfix />
|
||||
{children}
|
||||
<InlineChromiumBugfix />
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export const withInlineLink = (editor: CustomEditor) => {
|
||||
const { insertData, insertText, isInline } = editor;
|
||||
|
||||
editor.isInline = (element: CustomElement) => {
|
||||
return (
|
||||
((element as LinkElement).type &&
|
||||
['link'].includes((element as LinkElement).type)) ||
|
||||
isInline(element)
|
||||
);
|
||||
};
|
||||
|
||||
editor.insertText = (text) => {
|
||||
if (text && isUrl(text)) {
|
||||
LinkEditor.wrapLink(editor, text, text);
|
||||
} else {
|
||||
insertText(text);
|
||||
}
|
||||
};
|
||||
|
||||
editor.insertData = (data) => {
|
||||
const text = data.getData('text/plain');
|
||||
|
||||
if (text && isUrl(text)) {
|
||||
LinkEditor.wrapLink(editor, text, text);
|
||||
} else {
|
||||
insertData(data);
|
||||
}
|
||||
};
|
||||
|
||||
return editor;
|
||||
};
|
||||
30
packages/react-text/src/plugins/with-text.ts
Normal file
30
packages/react-text/src/plugins/with-text.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { ReactEditor } from 'slate-react';
|
||||
|
||||
export const withText = <T extends ReactEditor>(editor: T) => {
|
||||
const e = editor as T;
|
||||
const { insertData } = e;
|
||||
|
||||
e.insertBreak = () => {
|
||||
editor.insertText('\n');
|
||||
};
|
||||
|
||||
e.insertSoftBreak = () => {
|
||||
editor.insertText('\n');
|
||||
};
|
||||
|
||||
e.insertData = (data: DataTransfer) => {
|
||||
let text = data.getData('text/plain');
|
||||
const plaitData = data.getData(`application/x-slate-fragment`);
|
||||
if (!plaitData && text) {
|
||||
if (text.endsWith('\n')) {
|
||||
text = text.substring(0, text.length - 1);
|
||||
}
|
||||
text = text.trim().replace(/\t+/g, ' ');
|
||||
e.insertText(text);
|
||||
return;
|
||||
}
|
||||
insertData(data);
|
||||
};
|
||||
|
||||
return e;
|
||||
};
|
||||
27
packages/react-text/src/search-highlight.ts
Normal file
27
packages/react-text/src/search-highlight.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
let searchQuery = '';
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
function getSnapshot() {
|
||||
return searchQuery;
|
||||
}
|
||||
|
||||
/** 设置搜索高亮关键词(供搜索组件调用) */
|
||||
export function setSearchHighlightQuery(query: string) {
|
||||
if (searchQuery === query) return;
|
||||
searchQuery = query;
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
/** 订阅搜索高亮关键词(供 Text 组件内部使用) */
|
||||
export function useSearchHighlightQuery(): string {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
40
packages/react-text/src/styles/index.scss
Normal file
40
packages/react-text/src/styles/index.scss
Normal file
@@ -0,0 +1,40 @@
|
||||
.plait-board-container {
|
||||
.text {
|
||||
foreignObject {
|
||||
overflow: hidden;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
scrollbar-width: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.plait-text-container {
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
display: inline-block;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Noto Sans', 'Noto Sans CJK SC', 'Microsoft Yahei', 'Hiragino Sans GB', Arial, sans-serif;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.slate-editable-container {
|
||||
outline: none;
|
||||
padding: 0;
|
||||
cursor: default;
|
||||
& [data-slate-node='element'] {
|
||||
user-select: none;
|
||||
}
|
||||
&[contenteditable="true"] {
|
||||
cursor: text;
|
||||
& [data-slate-node='element'] {
|
||||
user-select: text;
|
||||
}
|
||||
}
|
||||
}
|
||||
64
packages/react-text/src/text.spec.tsx
Normal file
64
packages/react-text/src/text.spec.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { Text } from './text';
|
||||
import type { ParagraphElement } from '@plait/common';
|
||||
import type { CustomEditor } from './custom-types';
|
||||
|
||||
describe('Text', () => {
|
||||
it('should render successfully', () => {
|
||||
const ele: ParagraphElement = {
|
||||
children: [{ text: '' }],
|
||||
type: 'paragraph',
|
||||
};
|
||||
const { baseElement } = render(<Text text={ele} />);
|
||||
expect(baseElement).toBeTruthy();
|
||||
});
|
||||
|
||||
it('clears stale selection paths when text changes', async () => {
|
||||
let editor: CustomEditor | undefined;
|
||||
const longText: ParagraphElement = {
|
||||
children: Array.from({ length: 14 }, (_, index) => ({
|
||||
text: `line-${index}`,
|
||||
})),
|
||||
type: 'paragraph',
|
||||
};
|
||||
const shortText: ParagraphElement = {
|
||||
children: [{ text: 'short' }],
|
||||
type: 'paragraph',
|
||||
};
|
||||
|
||||
const { rerender } = render(
|
||||
<Text
|
||||
text={longText}
|
||||
afterInit={(initializedEditor) => {
|
||||
editor = initializedEditor as CustomEditor;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(editor).toBeDefined();
|
||||
});
|
||||
|
||||
editor!.selection = {
|
||||
anchor: { path: [0, 13], offset: 0 },
|
||||
focus: { path: [0, 13], offset: 0 },
|
||||
};
|
||||
editor!.history.undos.push([
|
||||
{
|
||||
type: 'remove_node',
|
||||
path: [0, 13],
|
||||
node: { text: 'line-13' },
|
||||
},
|
||||
]);
|
||||
|
||||
rerender(<Text text={shortText} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(editor!.selection).toBeNull();
|
||||
});
|
||||
expect(editor!.history.undos).toHaveLength(0);
|
||||
expect(() => editor!.undo()).not.toThrow();
|
||||
});
|
||||
});
|
||||
306
packages/react-text/src/text.tsx
Normal file
306
packages/react-text/src/text.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
import {
|
||||
createEditor,
|
||||
type Descendant,
|
||||
Range,
|
||||
Transforms,
|
||||
Text as SlateText,
|
||||
Node as SlateNode,
|
||||
type NodeEntry,
|
||||
type BaseRange,
|
||||
} from 'slate';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import {
|
||||
Editable,
|
||||
RenderElementProps,
|
||||
RenderLeafProps,
|
||||
Slate,
|
||||
withReact,
|
||||
} from 'slate-react';
|
||||
import {
|
||||
type CustomElement,
|
||||
type CustomText,
|
||||
type LinkElement,
|
||||
type ParagraphElement,
|
||||
type TextProps,
|
||||
} from '@plait/common';
|
||||
import React, { useMemo, useCallback, useEffect, CSSProperties } from 'react';
|
||||
import { withHistory } from 'slate-history';
|
||||
import { withText } from './plugins/with-text';
|
||||
import { type CustomEditor, RenderElementPropsFor } from './custom-types';
|
||||
import { useSearchHighlightQuery } from './search-highlight';
|
||||
|
||||
import './styles/index.scss';
|
||||
import { LinkComponent, withInlineLink } from './plugins/with-link';
|
||||
|
||||
export type TextComponentProps = TextProps;
|
||||
|
||||
export const Text: React.FC<TextComponentProps> = (
|
||||
props: TextComponentProps
|
||||
) => {
|
||||
const { text, readonly, onChange, onComposition, afterInit } = props;
|
||||
|
||||
const searchQuery = useSearchHighlightQuery();
|
||||
|
||||
const renderLeaf = useCallback(
|
||||
(props: RenderLeafProps) => <Leaf {...props} />,
|
||||
[]
|
||||
);
|
||||
|
||||
const decorate = useCallback(
|
||||
([node, path]: NodeEntry): (BaseRange & Record<string, unknown>)[] => {
|
||||
if (!searchQuery || !SlateText.isText(node)) return [];
|
||||
|
||||
const ranges: (BaseRange & Record<string, unknown>)[] = [];
|
||||
const { text: nodeText } = node;
|
||||
const escaped = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const regex = new RegExp(escaped, 'gi');
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(nodeText)) !== null) {
|
||||
ranges.push({
|
||||
anchor: { path, offset: match.index },
|
||||
focus: { path, offset: match.index + match[0].length },
|
||||
searchHighlight: true,
|
||||
} as BaseRange & Record<string, unknown>);
|
||||
}
|
||||
|
||||
return ranges;
|
||||
},
|
||||
[searchQuery]
|
||||
);
|
||||
|
||||
const initialValue: Descendant[] = [text];
|
||||
|
||||
const editor = useMemo(() => {
|
||||
const editor = withInlineLink(
|
||||
withText(withHistory(withReact(createEditor())))
|
||||
);
|
||||
afterInit && afterInit(editor);
|
||||
return editor;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (text === editor.children[0]) {
|
||||
return;
|
||||
}
|
||||
editor.children = [text];
|
||||
resetEditorHistory(editor);
|
||||
ensureValidSelection(editor);
|
||||
editor.onChange();
|
||||
}, [text, editor]);
|
||||
|
||||
const onKeyDown: React.KeyboardEventHandler<HTMLInputElement> = (event) => {
|
||||
const { selection } = editor;
|
||||
|
||||
// Default left/right behavior is unit:'character'.
|
||||
// This fails to distinguish between two cursor positions, such as
|
||||
// <inline>foo<cursor/></inline> vs <inline>foo</inline><cursor/>.
|
||||
// Here we modify the behavior to unit:'offset'.
|
||||
// This lets the user step into and out of the inline without stepping over characters.
|
||||
// You may wish to customize this further to only use unit:'offset' in specific cases.
|
||||
if (selection && Range.isCollapsed(selection)) {
|
||||
const { nativeEvent } = event;
|
||||
if (isKeyHotkey('left', nativeEvent)) {
|
||||
event.preventDefault();
|
||||
Transforms.move(editor, { unit: 'offset', reverse: true });
|
||||
return;
|
||||
}
|
||||
if (isKeyHotkey('right', nativeEvent)) {
|
||||
event.preventDefault();
|
||||
Transforms.move(editor, { unit: 'offset' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Slate
|
||||
editor={editor}
|
||||
initialValue={initialValue}
|
||||
onChange={(value: Descendant[]) => {
|
||||
onChange &&
|
||||
onChange({
|
||||
newText: editor.children[0] as ParagraphElement,
|
||||
operations: editor.operations,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Editable
|
||||
className="slate-editable-container plait-text-container"
|
||||
decorate={decorate}
|
||||
renderElement={(props) => <Element {...props} />}
|
||||
renderLeaf={renderLeaf}
|
||||
readOnly={readonly === undefined ? true : readonly}
|
||||
onCompositionStart={(event) => {
|
||||
if (onComposition) {
|
||||
onComposition(event as unknown as CompositionEvent);
|
||||
}
|
||||
}}
|
||||
onCompositionUpdate={(event) => {
|
||||
if (onComposition) {
|
||||
onComposition(event as unknown as CompositionEvent);
|
||||
}
|
||||
}}
|
||||
onCompositionEnd={(event) => {
|
||||
if (onComposition) {
|
||||
onComposition(event as unknown as CompositionEvent);
|
||||
}
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
</Slate>
|
||||
);
|
||||
};
|
||||
|
||||
const resetEditorHistory = (editor: CustomEditor) => {
|
||||
editor.history.undos = [];
|
||||
editor.history.redos = [];
|
||||
};
|
||||
|
||||
const ensureValidSelection = (editor: CustomEditor) => {
|
||||
const { selection } = editor;
|
||||
if (!selection) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
SlateNode.has(editor, selection.anchor.path) &&
|
||||
SlateNode.has(editor, selection.focus.path)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
Transforms.deselect(editor);
|
||||
};
|
||||
|
||||
const Element = (props: RenderElementProps) => {
|
||||
const { attributes, children, element } = props as RenderElementPropsFor<
|
||||
CustomElement & { type: string }
|
||||
>;
|
||||
switch (element.type) {
|
||||
case 'link':
|
||||
return (
|
||||
<LinkComponent {...(props as RenderElementPropsFor<LinkElement>)} />
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<ParagraphComponent
|
||||
{...(props as RenderElementPropsFor<ParagraphElement>)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const ParagraphComponent = ({
|
||||
attributes,
|
||||
children,
|
||||
element,
|
||||
}: RenderElementPropsFor<ParagraphElement>) => {
|
||||
const style = { textAlign: element.align } as CSSProperties;
|
||||
return (
|
||||
<div style={style} {...attributes}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Leaf: React.FC<RenderLeafProps> = ({ children, leaf, attributes }) => {
|
||||
if ((leaf as CustomText).bold) {
|
||||
children = <strong>{children}</strong>;
|
||||
}
|
||||
|
||||
if ((leaf as CustomText).code) {
|
||||
children = <code>{children}</code>;
|
||||
}
|
||||
|
||||
if ((leaf as CustomText).italic) {
|
||||
children = <em>{children}</em>;
|
||||
}
|
||||
|
||||
// 下划线和删除线统一通过 CSS 实现,以支持自定义样式和颜色
|
||||
// 不再使用 <u> 和 <s> 标签,避免 DOM 结构变化导致的渲染问题
|
||||
|
||||
// 上标支持
|
||||
if ((leaf as any).superscript) {
|
||||
children = <sup>{children}</sup>;
|
||||
}
|
||||
|
||||
// 下标支持
|
||||
if ((leaf as any).subscript) {
|
||||
children = <sub>{children}</sub>;
|
||||
}
|
||||
|
||||
const customText = leaf as CustomText;
|
||||
const fontSize = customText['font-size'];
|
||||
|
||||
// 扩展的自定义样式
|
||||
const fontFamily = (leaf as any)['font-family'];
|
||||
const fontWeight = (leaf as any)['font-weight'];
|
||||
const textShadow = (leaf as any)['text-shadow'];
|
||||
const textGradient = (leaf as any)['text-gradient'];
|
||||
const lineHeight = (leaf as any)['line-height'];
|
||||
const letterSpacing = (leaf as any)['letter-spacing'];
|
||||
const backgroundColor = (leaf as any)['background-color'];
|
||||
const textTransform = (leaf as any)['text-transform'];
|
||||
const textStroke = (leaf as any)['text-stroke'];
|
||||
const textStrokeColor = (leaf as any)['text-stroke-color'];
|
||||
const textDecorationStyle = (leaf as any)['text-decoration-style'];
|
||||
const textDecorationColor = (leaf as any)['text-decoration-color'];
|
||||
|
||||
const style: CSSProperties = {
|
||||
color: customText.color,
|
||||
fontSize: fontSize ? `${fontSize}px` : undefined,
|
||||
// 自定义文本效果样式
|
||||
fontFamily: fontFamily || undefined,
|
||||
fontWeight: fontWeight || undefined,
|
||||
textShadow: textShadow || undefined,
|
||||
lineHeight: lineHeight || undefined,
|
||||
letterSpacing: letterSpacing || undefined,
|
||||
// 新增样式
|
||||
backgroundColor: backgroundColor || undefined,
|
||||
textTransform: textTransform || undefined,
|
||||
};
|
||||
|
||||
// 文字描边
|
||||
if (textStroke) {
|
||||
(style as any).WebkitTextStroke = `${textStroke}px ${textStrokeColor || '#000000'}`;
|
||||
}
|
||||
|
||||
// 下划线/删除线 - 统一使用 CSS 实现
|
||||
const isUnderlined = (leaf as CustomText).underlined;
|
||||
const isStrikethrough = (leaf as any).strikethrough;
|
||||
|
||||
if (isUnderlined || isStrikethrough) {
|
||||
const decorations: string[] = [];
|
||||
if (isUnderlined) decorations.push('underline');
|
||||
if (isStrikethrough) decorations.push('line-through');
|
||||
|
||||
style.textDecoration = decorations.join(' ');
|
||||
if (textDecorationStyle) {
|
||||
style.textDecorationStyle = textDecorationStyle as any;
|
||||
}
|
||||
if (textDecorationColor) {
|
||||
style.textDecorationColor = textDecorationColor;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有文字渐变,设置渐变相关样式
|
||||
if (textGradient) {
|
||||
style.backgroundImage = textGradient;
|
||||
style.backgroundClip = 'text';
|
||||
style.WebkitBackgroundClip = 'text';
|
||||
style.WebkitTextFillColor = 'transparent';
|
||||
// 清除可能冲突的 color
|
||||
style.color = undefined;
|
||||
}
|
||||
|
||||
// 搜索高亮(覆盖用户背景色,确保可见性)
|
||||
if ((leaf as any).searchHighlight) {
|
||||
style.backgroundColor = 'rgba(255, 235, 59, 0.6)';
|
||||
style.borderRadius = '2px';
|
||||
}
|
||||
|
||||
return (
|
||||
<span style={style} {...attributes}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user