Files
AFFiNE-Mirror/blocksuite/affine/rich-text/src/rich-text.ts
2025-03-28 07:20:34 +00:00

458 lines
13 KiB
TypeScript

import type {
AffineInlineEditor,
AffineTextAttributes,
} from '@blocksuite/affine-shared/types';
import { WithDisposable } from '@blocksuite/global/lit';
import { ShadowlessElement } from '@blocksuite/std';
import {
type AttributeRenderer,
InlineEditor,
type InlineMarkdownMatch,
type InlineRange,
type InlineRangeProvider,
type VLine,
} from '@blocksuite/std/inline';
import type { DeltaInsert } from '@blocksuite/store';
import { Text } from '@blocksuite/store';
import { effect, signal } 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 { onVBeforeinput, onVCompositionEnd } from './hooks.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 readonly _inlineEditor$ = signal<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$.value;
}
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$.value = new InlineEditor<AffineTextAttributes>(
this._yText,
{
isEmbed: delta => this.embedChecker(delta),
hooks: {
beforeinput: onVBeforeinput,
compositionEnd: onVCompositionEnd,
},
inlineRangeProvider: this.inlineRangeProvider,
vLineRenderer: this.vLineRenderer,
}
);
const inlineEditor = this._inlineEditor$.value;
if (this.attributesSchema) {
inlineEditor.setAttributeSchema(this.attributesSchema);
}
if (this.attributeRenderer) {
inlineEditor.setAttributeRenderer(this.attributeRenderer);
}
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$.value = 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;
}