Initial TrueGrowth source import

This commit is contained in:
2026-07-07 09:36:36 +08:00
commit 3b6781d695
2283 changed files with 691996 additions and 0 deletions

View 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;
};
}
}

View File

@@ -0,0 +1,3 @@
export * from './text';
export * from './custom-types';
export { setSearchHighlightQuery } from './search-highlight';

View File

@@ -0,0 +1,2 @@
export * from './with-text';
export * from './with-link';

View 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;
};

View 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;
};

View 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);
}

View 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;
}
}
}

View 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();
});
});

View 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>
);
};