Files
AFFiNE-Mirror/blocksuite/affine/blocks/database/src/properties/rich-text/cell-renderer.ts
L-Sun 8ce85f708d feat(editor): comment extension (#12948)
#### PR Dependency Tree


* **PR #12948** 👈
  * **PR #12980**

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Introduced inline comment functionality, allowing users to add,
resolve, and highlight comments directly within text.
  * Added a new toolbar action for inserting comments when supported.
* Inline comments are visually highlighted and can be interacted with in
the editor.

* **Enhancements**
  * Integrated a feature flag to enable or disable the comment feature.
* Improved inline manager rendering to support wrapper specs for
advanced formatting.

* **Developer Tools**
* Added mock comment provider for testing and development environments.

* **Chores**
* Updated dependencies and project references to support the new inline
comment module.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-02 09:14:34 +00:00

431 lines
12 KiB
TypeScript

import { DefaultInlineManagerExtension } from '@blocksuite/affine-inline-preset';
import type { RichText } from '@blocksuite/affine-rich-text';
import {
ParseDocUrlProvider,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import type {
AffineInlineEditor,
AffineTextAttributes,
} from '@blocksuite/affine-shared/types';
import {
getViewportElement,
isValidUrl,
} from '@blocksuite/affine-shared/utils';
import {
BaseCellRenderer,
createFromBaseCellRenderer,
createIcon,
} from '@blocksuite/data-view';
import { IS_MAC } from '@blocksuite/global/env';
import type { BlockSnapshot, DeltaInsert } from '@blocksuite/store';
import { Text } from '@blocksuite/store';
import { computed, effect, signal } from '@preact/signals-core';
import { ref } from 'lit/directives/ref.js';
import { html } from 'lit/static-html.js';
import { EditorHostKey } from '../../context/host-context.js';
import type { DatabaseBlockComponent } from '../../database-block.js';
import {
richTextCellStyle,
richTextContainerStyle,
} from './cell-renderer-css.js';
import { richTextPropertyModelConfig } from './define.js';
function toggleStyle(
inlineEditor: AffineInlineEditor | null,
attrs: AffineTextAttributes
): void {
if (!inlineEditor) return;
const inlineRange = inlineEditor.getInlineRange();
if (!inlineRange) return;
const root = inlineEditor.rootElement;
if (!root) {
return;
}
const deltas = inlineEditor.getDeltasByInlineRange(inlineRange);
let oldAttributes: AffineTextAttributes = {};
for (const [delta] of deltas) {
const attributes = delta.attributes;
if (!attributes) {
continue;
}
oldAttributes = { ...attributes };
}
const newAttributes = Object.fromEntries(
Object.entries(attrs).map(([k, v]) => {
if (
typeof v === 'boolean' &&
v === (oldAttributes as Record<string, unknown>)[k]
) {
return [k, !v];
} else {
return [k, v];
}
})
) as AffineTextAttributes;
inlineEditor.formatText(inlineRange, newAttributes, {
mode: 'merge',
});
root.blur();
inlineEditor.syncInlineRange();
}
export class RichTextCell extends BaseCellRenderer<Text, string> {
inlineEditor$ = computed(() => {
return this.richText$.value?.inlineEditor;
});
get inlineManager() {
return this.view
.serviceGet(EditorHostKey)
?.std.get(DefaultInlineManagerExtension.identifier);
}
get topContenteditableElement() {
const databaseBlock =
this.closest<DatabaseBlockComponent>('affine-database');
return databaseBlock?.topContenteditableElement;
}
get host() {
return this.view.serviceGet(EditorHostKey);
}
private readonly richText$ = signal<RichText>();
private changeUserSelectAccordToReadOnly() {
if (this && this instanceof HTMLElement) {
this.style.userSelect = this.readonly ? 'text' : 'none';
}
}
private readonly _handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') {
if (event.key === 'Tab') {
event.preventDefault();
return;
}
event.stopPropagation();
}
if (event.key === 'Enter' && !event.isComposing) {
if (event.shiftKey) {
// soft enter
this._onSoftEnter();
} else {
// exit editing
this.selectCurrentCell(false);
}
event.preventDefault();
return;
}
const inlineEditor = this.inlineEditor$.value;
if (!inlineEditor) return;
switch (event.key) {
// bold ctrl+b
case 'B':
case 'b':
if (event.metaKey || event.ctrlKey) {
event.preventDefault();
toggleStyle(inlineEditor, { bold: true });
}
break;
// italic ctrl+i
case 'I':
case 'i':
if (event.metaKey || event.ctrlKey) {
event.preventDefault();
toggleStyle(inlineEditor, { italic: true });
}
break;
// underline ctrl+u
case 'U':
case 'u':
if (event.metaKey || event.ctrlKey) {
event.preventDefault();
toggleStyle(inlineEditor, { underline: true });
}
break;
// strikethrough ctrl+shift+s
case 'S':
case 's':
if ((event.metaKey || event.ctrlKey) && event.shiftKey) {
event.preventDefault();
toggleStyle(inlineEditor, { strike: true });
}
break;
// inline code ctrl+shift+e
case 'E':
case 'e':
if ((event.metaKey || event.ctrlKey) && event.shiftKey) {
event.preventDefault();
toggleStyle(inlineEditor, { code: true });
}
break;
default:
break;
}
};
private readonly _initYText = (text?: string) => {
const yText = new Text(text);
this.valueSetImmediate(yText);
};
private readonly _onSoftEnter = () => {
if (this.value && this.inlineEditor$.value) {
const inlineRange = this.inlineEditor$.value.getInlineRange();
if (!inlineRange) return;
const text = new Text(this.inlineEditor$.value.yText);
text.replace(inlineRange.index, inlineRange.length, '\n');
this.inlineEditor$.value.setInlineRange({
index: inlineRange.index + 1,
length: 0,
});
}
};
private readonly _onCopy = (e: ClipboardEvent) => {
const inlineEditor = this.inlineEditor$.value;
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$.value;
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) => {
e.preventDefault();
e.stopPropagation();
const inlineEditor = this.inlineEditor$.value;
if (!inlineEditor) return;
const inlineRange = inlineEditor.getInlineRange();
if (!inlineRange) return;
if (e.clipboardData) {
try {
const getDeltas = (snapshot: BlockSnapshot): DeltaInsert[] => {
// @ts-expect-error FIXME: ts error
const text = snapshot.props?.text?.delta;
return text
? [...text, ...(snapshot.children?.flatMap(getDeltas) ?? [])]
: snapshot.children?.flatMap(getDeltas);
};
const snapshot = this.std?.clipboard?.readFromClipboard(
e.clipboardData
)['BLOCKSUITE/SNAPSHOT'];
const deltas = (
JSON.parse(snapshot).snapshot.content as BlockSnapshot[]
).flatMap(getDeltas);
deltas.forEach(delta => this.insertDelta(delta));
return;
} catch {
//
}
}
const text = e.clipboardData
?.getData('text/plain')
?.replace(/\r?\n|\r/g, '\n');
if (!text) return;
if (isValidUrl(text)) {
const std = this.std;
const result = std?.getOptional(ParseDocUrlProvider)?.parseDocUrl(text);
if (result) {
const text = ' ';
inlineEditor.insertText(inlineRange, text, {
reference: {
type: 'LinkedPage',
pageId: result.docId,
params: {
blockIds: result.blockIds,
elementIds: result.elementIds,
mode: result.mode,
},
},
});
inlineEditor.setInlineRange({
index: inlineRange.index + text.length,
length: 0,
});
// Track when a linked doc is created in database rich-text column
std?.getOptional(TelemetryProvider)?.track('LinkedDocCreated', {
module: 'database rich-text cell',
type: 'paste',
segment: 'database',
parentFlavour: 'affine:database',
});
} else {
inlineEditor.insertText(inlineRange, text, {
link: text,
});
inlineEditor.setInlineRange({
index: inlineRange.index + text.length,
length: 0,
});
}
} else {
inlineEditor.insertText(inlineRange, text);
inlineEditor.setInlineRange({
index: inlineRange.index + text.length,
length: 0,
});
}
};
override connectedCallback() {
super.connectedCallback();
this.classList.add(richTextCellStyle);
this.changeUserSelectAccordToReadOnly();
const selectAll = (e: KeyboardEvent) => {
if (e.key === 'a' && (IS_MAC ? e.metaKey : e.ctrlKey)) {
e.stopPropagation();
e.preventDefault();
this.inlineEditor$.value?.selectAll();
}
};
this.disposables.addFromEvent(this, 'keydown', selectAll);
this.disposables.add(
effect(() => {
const editor = this.inlineEditor$.value;
if (editor) {
const disposable = editor.slots.keydown.subscribe(
this._handleKeyDown
);
return () => disposable.unsubscribe();
}
return;
})
);
this.disposables.add(
effect(() => {
const richText = this.richText$.value;
if (richText) {
richText.addEventListener('copy', this._onCopy, true);
richText.addEventListener('cut', this._onCut, true);
richText.addEventListener('paste', this._onPaste, true);
return () => {
richText.removeEventListener('copy', this._onCopy);
richText.removeEventListener('cut', this._onCut);
richText.removeEventListener('paste', this._onPaste);
};
}
return;
})
);
}
override beforeEnterEditMode() {
if (!this.value || typeof this.value === 'string') {
this._initYText(this.value);
}
return true;
}
override afterEnterEditingMode() {
this.inlineEditor$.value?.focusEnd();
}
override render() {
if (!this.value || !(this.value instanceof Text)) {
return html` <div class="${richTextContainerStyle}"></div>`;
}
return html` <rich-text
${ref(this.richText$)}
data-disable-ask-ai
data-not-block-text
.yText="${this.value}"
.inlineEventSource="${this.topContenteditableElement}"
.attributesSchema="${this.inlineManager?.getSchema()}"
.attributeRenderer="${this.inlineManager?.getRenderer()}"
.embedChecker="${this.inlineManager?.embedChecker}"
.markdownMatches="${this.inlineManager?.markdownMatches}"
.readonly="${!this.isEditing$.value || this.readonly}"
.verticalScrollContainerGetter="${() =>
this.topContenteditableElement?.host
? getViewportElement(this.topContenteditableElement.host)
: null}"
class="${richTextContainerStyle} inline-editor"
></rich-text>`;
}
private get std() {
return this.view.serviceGet(EditorHostKey)?.std;
}
insertDelta = (delta: DeltaInsert<AffineTextAttributes>) => {
const inlineEditor = this.inlineEditor$.value;
const range = inlineEditor?.getInlineRange();
if (!range || !delta.insert) {
return;
}
inlineEditor?.insertText(range, delta.insert, delta.attributes);
inlineEditor?.setInlineRange({
index: range.index + delta.insert.length,
length: 0,
});
};
}
declare global {
interface HTMLElementTagNameMap {
'affine-database-rich-text-cell': RichTextCell;
}
}
export const richTextColumnConfig =
richTextPropertyModelConfig.createPropertyMeta({
icon: createIcon('TextIcon'),
cellRenderer: {
view: createFromBaseCellRenderer(RichTextCell),
},
});