mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-19 23:37:15 +08:00
feat(editor): rich text package (#10689)
This PR performs a significant architectural refactoring by extracting rich text functionality into a dedicated package. Here are the key changes: 1. **New Package Creation** - Created a new package `@blocksuite/affine-rich-text` to house rich text related functionality - Moved rich text components, utilities, and types from `@blocksuite/affine-components` to this new package 2. **Dependency Updates** - Updated multiple block packages to include the new `@blocksuite/affine-rich-text` as a direct dependency: - block-callout - block-code - block-database - block-edgeless-text - block-embed - block-list - block-note - block-paragraph 3. **Import Path Updates** - Refactored all imports that previously referenced rich text functionality from `@blocksuite/affine-components/rich-text` to now use `@blocksuite/affine-rich-text` - Updated imports for components like: - DefaultInlineManagerExtension - RichText types and interfaces - Text manipulation utilities (focusTextModel, textKeymap, etc.) - Reference node components and providers 4. **Build Configuration Updates** - Added references to the new rich text package in the `tsconfig.json` files of all affected packages - Maintained workspace dependencies using the `workspace:*` version specifier The primary motivation appears to be: 1. Better separation of concerns by isolating rich text functionality 2. Improved maintainability through more modular package structure 3. Clearer dependencies between packages 4. Potential for better tree-shaking and bundle optimization This is primarily an architectural improvement that should make the codebase more maintainable and better organized.
This commit is contained in:
452
blocksuite/affine/rich-text/src/rich-text.ts
Normal file
452
blocksuite/affine/rich-text/src/rich-text.ts
Normal file
@@ -0,0 +1,452 @@
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import {
|
||||
type AttributeRenderer,
|
||||
type DeltaInsert,
|
||||
InlineEditor,
|
||||
type InlineRange,
|
||||
type InlineRangeProvider,
|
||||
type VLine,
|
||||
} from '@blocksuite/inline';
|
||||
import { Text } from '@blocksuite/store';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import { css, html, type TemplateResult } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import * as Y from 'yjs';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { InlineMarkdownMatch } from './extension/type.js';
|
||||
import { onVBeforeinput, onVCompositionEnd } from './hooks.js';
|
||||
import type { AffineInlineEditor } from './inline/index.js';
|
||||
|
||||
interface RichTextStackItem {
|
||||
meta: Map<'richtext-v-range', InlineRange | null>;
|
||||
}
|
||||
|
||||
export class RichText extends WithDisposable(ShadowlessElement) {
|
||||
static override styles = css`
|
||||
rich-text {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
|
||||
scroll-margin-top: 50px;
|
||||
scroll-margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.inline-editor {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.inline-editor.readonly {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
rich-text .nowrap-lines v-text span,
|
||||
rich-text .nowrap-lines v-element span {
|
||||
white-space: pre !important;
|
||||
}
|
||||
`;
|
||||
|
||||
#verticalScrollContainer: HTMLElement | null = null;
|
||||
|
||||
private _inlineEditor: AffineInlineEditor | null = null;
|
||||
|
||||
private readonly _onCopy = (e: ClipboardEvent) => {
|
||||
const inlineEditor = this.inlineEditor;
|
||||
if (!inlineEditor) return;
|
||||
|
||||
const inlineRange = inlineEditor.getInlineRange();
|
||||
if (!inlineRange) return;
|
||||
|
||||
const text = inlineEditor.yTextString.slice(
|
||||
inlineRange.index,
|
||||
inlineRange.index + inlineRange.length
|
||||
);
|
||||
|
||||
e.clipboardData?.setData('text/plain', text);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
private readonly _onCut = (e: ClipboardEvent) => {
|
||||
const inlineEditor = this.inlineEditor;
|
||||
if (!inlineEditor) return;
|
||||
|
||||
const inlineRange = inlineEditor.getInlineRange();
|
||||
if (!inlineRange) return;
|
||||
|
||||
const text = inlineEditor.yTextString.slice(
|
||||
inlineRange.index,
|
||||
inlineRange.index + inlineRange.length
|
||||
);
|
||||
inlineEditor.deleteText(inlineRange);
|
||||
inlineEditor.setInlineRange({
|
||||
index: inlineRange.index,
|
||||
length: 0,
|
||||
});
|
||||
|
||||
e.clipboardData?.setData('text/plain', text);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
private readonly _onPaste = (e: ClipboardEvent) => {
|
||||
const inlineEditor = this.inlineEditor;
|
||||
if (!inlineEditor) return;
|
||||
|
||||
const inlineRange = inlineEditor.getInlineRange();
|
||||
if (!inlineRange) return;
|
||||
|
||||
const text = e.clipboardData
|
||||
?.getData('text/plain')
|
||||
?.replace(/\r?\n|\r/g, '\n');
|
||||
if (!text) return;
|
||||
|
||||
inlineEditor.insertText(inlineRange, text);
|
||||
inlineEditor.setInlineRange({
|
||||
index: inlineRange.index + text.length,
|
||||
length: 0,
|
||||
});
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
private readonly _onStackItemAdded = (event: {
|
||||
stackItem: RichTextStackItem;
|
||||
}) => {
|
||||
const inlineRange = this.inlineEditor?.getInlineRange();
|
||||
if (inlineRange) {
|
||||
event.stackItem.meta.set('richtext-v-range', inlineRange);
|
||||
}
|
||||
};
|
||||
|
||||
private readonly _onStackItemPopped = (event: {
|
||||
stackItem: RichTextStackItem;
|
||||
}) => {
|
||||
const inlineRange = event.stackItem.meta.get('richtext-v-range');
|
||||
if (inlineRange && this.inlineEditor?.isValidInlineRange(inlineRange)) {
|
||||
this.inlineEditor?.setInlineRange(inlineRange);
|
||||
}
|
||||
};
|
||||
|
||||
private get _yText() {
|
||||
return this.yText instanceof Text ? this.yText.yText : this.yText;
|
||||
}
|
||||
|
||||
// It will listen ctrl+z/ctrl+shift+z and call undoManager.undo/redo, keydown event will not
|
||||
get inlineEditor() {
|
||||
return this._inlineEditor;
|
||||
}
|
||||
|
||||
get inlineEditorContainer() {
|
||||
return this._inlineEditorContainer;
|
||||
}
|
||||
|
||||
private _init() {
|
||||
if (this._inlineEditor) {
|
||||
console.error('Inline editor already exists.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.enableFormat) {
|
||||
this.attributesSchema = z.object({});
|
||||
}
|
||||
|
||||
// init inline editor
|
||||
this._inlineEditor = new InlineEditor<AffineTextAttributes>(this._yText, {
|
||||
isEmbed: delta => this.embedChecker(delta),
|
||||
hooks: {
|
||||
beforeinput: onVBeforeinput,
|
||||
compositionEnd: onVCompositionEnd,
|
||||
},
|
||||
inlineRangeProvider: this.inlineRangeProvider,
|
||||
vLineRenderer: this.vLineRenderer,
|
||||
});
|
||||
if (this.attributesSchema) {
|
||||
this._inlineEditor.setAttributeSchema(this.attributesSchema);
|
||||
}
|
||||
if (this.attributeRenderer) {
|
||||
this._inlineEditor.setAttributeRenderer(this.attributeRenderer);
|
||||
}
|
||||
const inlineEditor = this._inlineEditor;
|
||||
|
||||
const markdownMatches = this.markdownMatches;
|
||||
if (markdownMatches) {
|
||||
inlineEditor.disposables.addFromEvent(
|
||||
this.inlineEventSource ?? this.inlineEditorContainer,
|
||||
'keydown',
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key !== ' ' && e.key !== 'Enter') return;
|
||||
|
||||
const inlineRange = inlineEditor.getInlineRange();
|
||||
if (!inlineRange || inlineRange.length > 0) return;
|
||||
|
||||
const nearestLineBreakIndex = inlineEditor.yTextString
|
||||
.slice(0, inlineRange.index)
|
||||
.lastIndexOf('\n');
|
||||
const prefixText = inlineEditor.yTextString.slice(
|
||||
nearestLineBreakIndex + 1,
|
||||
inlineRange.index
|
||||
);
|
||||
|
||||
for (const match of markdownMatches) {
|
||||
const { pattern, action } = match;
|
||||
if (prefixText.match(pattern)) {
|
||||
action({
|
||||
inlineEditor,
|
||||
prefixText,
|
||||
inlineRange,
|
||||
pattern,
|
||||
undoManager: this.undoManager,
|
||||
});
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// init auto scroll
|
||||
inlineEditor.disposables.add(
|
||||
effect(() => {
|
||||
const inlineRange = inlineEditor.inlineRange$.value;
|
||||
if (!inlineRange) return;
|
||||
|
||||
// lazy
|
||||
const verticalScrollContainer =
|
||||
this.#verticalScrollContainer ||
|
||||
(this.#verticalScrollContainer =
|
||||
this.verticalScrollContainerGetter?.() || null);
|
||||
|
||||
inlineEditor
|
||||
.waitForUpdate()
|
||||
.then(() => {
|
||||
if (!inlineEditor.mounted || inlineEditor.rendering) return;
|
||||
|
||||
const range = inlineEditor.toDomRange(inlineRange);
|
||||
if (!range) return;
|
||||
|
||||
if (verticalScrollContainer) {
|
||||
const nativeRange = inlineEditor.getNativeRange();
|
||||
if (
|
||||
!nativeRange ||
|
||||
nativeRange.commonAncestorContainer.parentElement?.contains(
|
||||
inlineEditor.rootElement
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
const containerRect =
|
||||
verticalScrollContainer.getBoundingClientRect();
|
||||
const rangeRect = range.getBoundingClientRect();
|
||||
|
||||
if (rangeRect.top < containerRect.top) {
|
||||
this.scrollIntoView({ block: 'start' });
|
||||
} else if (rangeRect.bottom > containerRect.bottom) {
|
||||
this.scrollIntoView({ block: 'end' });
|
||||
}
|
||||
}
|
||||
|
||||
// scroll container is this
|
||||
if (this.enableAutoScrollHorizontally) {
|
||||
const containerRect = this.getBoundingClientRect();
|
||||
const rangeRect = range.getBoundingClientRect();
|
||||
|
||||
let scrollLeft = this.scrollLeft;
|
||||
if (
|
||||
rangeRect.left + rangeRect.width >
|
||||
containerRect.left + containerRect.width
|
||||
) {
|
||||
scrollLeft +=
|
||||
rangeRect.left +
|
||||
rangeRect.width -
|
||||
(containerRect.left + containerRect.width) +
|
||||
2;
|
||||
}
|
||||
this.scrollLeft = scrollLeft;
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
})
|
||||
);
|
||||
|
||||
inlineEditor.mount(
|
||||
this.inlineEditorContainer,
|
||||
this.inlineEventSource,
|
||||
this.readonly
|
||||
);
|
||||
}
|
||||
|
||||
private _unmount() {
|
||||
if (this.inlineEditor?.mounted) {
|
||||
this.inlineEditor.unmount();
|
||||
}
|
||||
this._inlineEditor = null;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
if (!this._yText) {
|
||||
console.error('rich-text need yText to init.');
|
||||
return;
|
||||
}
|
||||
if (!this._yText.doc) {
|
||||
console.error('yText should be bind to yDoc.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.undoManager) {
|
||||
this.undoManager = new Y.UndoManager(this._yText, {
|
||||
trackedOrigins: new Set([this._yText.doc.clientID]),
|
||||
});
|
||||
}
|
||||
|
||||
if (this.enableUndoRedo) {
|
||||
this.disposables.addFromEvent(this, 'keydown', (e: KeyboardEvent) => {
|
||||
// eslint-disable-next-line sonarjs/no-collapsible-if
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
if (e.key === 'z' || e.key === 'Z') {
|
||||
if (e.shiftKey) {
|
||||
this.undoManager.redo();
|
||||
} else {
|
||||
this.undoManager.undo();
|
||||
}
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.undoManager.on('stack-item-added', this._onStackItemAdded);
|
||||
this.undoManager.on('stack-item-popped', this._onStackItemPopped);
|
||||
this.disposables.add({
|
||||
dispose: () => {
|
||||
this.undoManager.off('stack-item-added', this._onStackItemAdded);
|
||||
this.undoManager.off('stack-item-popped', this._onStackItemPopped);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (this.enableClipboard) {
|
||||
this.disposables.addFromEvent(this, 'copy', this._onCopy);
|
||||
this.disposables.addFromEvent(this, 'cut', this._onCut);
|
||||
this.disposables.addFromEvent(this, 'paste', this._onPaste);
|
||||
}
|
||||
|
||||
this.updateComplete
|
||||
.then(() => {
|
||||
this._unmount();
|
||||
this._init();
|
||||
|
||||
this.disposables.add({
|
||||
dispose: () => {
|
||||
this._unmount();
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
override async getUpdateComplete(): Promise<boolean> {
|
||||
const result = await super.getUpdateComplete();
|
||||
await this.inlineEditor?.waitForUpdate();
|
||||
return result;
|
||||
}
|
||||
|
||||
// If it is true rich-text will handle undo/redo by itself. (including v-range restore)
|
||||
override render() {
|
||||
const classes = classMap({
|
||||
'inline-editor': true,
|
||||
'nowrap-lines': !this.wrapText,
|
||||
readonly: this.readonly,
|
||||
});
|
||||
|
||||
return html`<div
|
||||
contenteditable=${this.readonly ? 'false' : 'true'}
|
||||
class=${classes}
|
||||
></div>`;
|
||||
}
|
||||
|
||||
override updated(changedProperties: Map<string | number | symbol, unknown>) {
|
||||
const inlineEditor = this.inlineEditor;
|
||||
if (inlineEditor && this._yText && this._yText !== inlineEditor.yText) {
|
||||
this._unmount();
|
||||
this._init();
|
||||
return;
|
||||
}
|
||||
if (this._inlineEditor && changedProperties.has('readonly')) {
|
||||
this._inlineEditor.setReadonly(this.readonly);
|
||||
}
|
||||
}
|
||||
|
||||
@query('.inline-editor')
|
||||
private accessor _inlineEditorContainer!: HTMLDivElement;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor attributeRenderer: AttributeRenderer | undefined = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor attributesSchema: z.ZodSchema | undefined = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor embedChecker: <
|
||||
TextAttributes extends AffineTextAttributes = AffineTextAttributes,
|
||||
>(
|
||||
delta: DeltaInsert<TextAttributes>
|
||||
) => boolean = () => false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor enableAutoScrollHorizontally = true;
|
||||
|
||||
// If it is true rich-text will prevent events related to clipboard bubbling up and handle them by itself.
|
||||
@property({ attribute: false })
|
||||
accessor enableClipboard = true;
|
||||
|
||||
// `attributesSchema` will be overwritten to `z.object({})` if `enableFormat` is false.
|
||||
@property({ attribute: false })
|
||||
accessor enableFormat = true;
|
||||
|
||||
// bubble up if pressed ctrl+z/ctrl+shift+z.
|
||||
@property({ attribute: false })
|
||||
accessor enableUndoRedo = true;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor inlineEventSource: HTMLElement | undefined = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor inlineRangeProvider: InlineRangeProvider | undefined = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor markdownMatches: InlineMarkdownMatch<AffineTextAttributes>[] = [];
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor readonly = false;
|
||||
|
||||
// rich-text will create a undoManager if it is not provided.
|
||||
@property({ attribute: false })
|
||||
accessor undoManager!: Y.UndoManager;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor verticalScrollContainerGetter:
|
||||
| (() => HTMLElement | null)
|
||||
| undefined = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor vLineRenderer: ((vLine: VLine) => TemplateResult) | undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor wrapText = true;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor yText!: Y.Text | Text;
|
||||
}
|
||||
Reference in New Issue
Block a user