mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 13:25:12 +00:00
feat(editor): support linked-doc in rich-text column (#9634)
close: BS-2345
This commit is contained in:
@@ -1,10 +1,18 @@
|
||||
import {
|
||||
type AffineInlineEditor,
|
||||
DefaultInlineManagerExtension,
|
||||
type RichText,
|
||||
import type {
|
||||
AffineInlineEditor,
|
||||
RichText,
|
||||
} from '@blocksuite/affine-components/rich-text';
|
||||
import { DefaultInlineManagerExtension } from '@blocksuite/affine-components/rich-text';
|
||||
import type { RootBlockModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
ParseDocUrlProvider,
|
||||
TelemetryProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import { getViewportElement } from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
getViewportElement,
|
||||
isValidUrl,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
BaseCellRenderer,
|
||||
createFromBaseCellRenderer,
|
||||
@@ -12,8 +20,11 @@ import {
|
||||
} from '@blocksuite/data-view';
|
||||
import { IS_MAC } from '@blocksuite/global/env';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import type { DeltaInsert } from '@blocksuite/inline';
|
||||
import type { BlockSnapshot } from '@blocksuite/store';
|
||||
import { Text } from '@blocksuite/store';
|
||||
import { css, nothing, type PropertyValues } from 'lit';
|
||||
import { computed, signal } from '@preact/signals-core';
|
||||
import { css, nothing } from 'lit';
|
||||
import { query } from 'lit/decorators.js';
|
||||
import { keyed } from 'lit/directives/keyed.js';
|
||||
import { html } from 'lit/static-html.js';
|
||||
@@ -23,9 +34,11 @@ import type { DatabaseBlockComponent } from '../../database-block.js';
|
||||
import { richTextColumnModelConfig } from './define.js';
|
||||
|
||||
function toggleStyle(
|
||||
inlineEditor: AffineInlineEditor,
|
||||
inlineEditor: AffineInlineEditor | null,
|
||||
attrs: AffineTextAttributes
|
||||
): void {
|
||||
if (!inlineEditor) return;
|
||||
|
||||
const inlineRange = inlineEditor.getInlineRange();
|
||||
if (!inlineRange) return;
|
||||
|
||||
@@ -68,7 +81,113 @@ function toggleStyle(
|
||||
inlineEditor.syncInlineRange();
|
||||
}
|
||||
|
||||
export class RichTextCell extends BaseCellRenderer<Text> {
|
||||
abstract class BaseRichTextCell extends BaseCellRenderer<Text> {
|
||||
static override styles = css`
|
||||
affine-database-rich-text-cell,
|
||||
affine-database-rich-text-cell-editing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.affine-database-rich-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
outline: none;
|
||||
font-size: var(--data-view-cell-text-size);
|
||||
line-height: var(--data-view-cell-text-line-height);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.affine-database-rich-text v-line {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.affine-database-rich-text v-line > div {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.data-view-header-area-icon {
|
||||
height: max-content;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 8px;
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
margin-top: 2px;
|
||||
background-color: var(--affine-background-secondary-color);
|
||||
}
|
||||
|
||||
.data-view-header-area-icon svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
fill: var(--affine-icon-color);
|
||||
color: var(--affine-icon-color);
|
||||
}
|
||||
`;
|
||||
|
||||
get inlineEditor() {
|
||||
return this.richText?.inlineEditor;
|
||||
}
|
||||
|
||||
get inlineManager() {
|
||||
return this.view
|
||||
.contextGet(HostContextKey)
|
||||
?.std.get(DefaultInlineManagerExtension.identifier);
|
||||
}
|
||||
|
||||
get service() {
|
||||
return this.view
|
||||
.contextGet(HostContextKey)
|
||||
?.std.getService('affine:database');
|
||||
}
|
||||
|
||||
get topContenteditableElement() {
|
||||
const databaseBlock =
|
||||
this.closest<DatabaseBlockComponent>('affine-database');
|
||||
return databaseBlock?.topContenteditableElement;
|
||||
}
|
||||
|
||||
get attributeRenderer() {
|
||||
return this.inlineManager?.getRenderer();
|
||||
}
|
||||
|
||||
get attributesSchema() {
|
||||
return this.inlineManager?.getSchema();
|
||||
}
|
||||
|
||||
get host() {
|
||||
return this.view.contextGet(HostContextKey);
|
||||
}
|
||||
|
||||
@query('rich-text')
|
||||
accessor richText!: RichText;
|
||||
|
||||
@query('.affine-database-rich-text')
|
||||
accessor _richTextElement!: HTMLElement;
|
||||
|
||||
docId$ = signal<string>();
|
||||
|
||||
isLinkedDoc$ = computed(() => false);
|
||||
|
||||
linkedDocTitle$ = computed(() => {
|
||||
if (!this.docId$.value) {
|
||||
return this.value;
|
||||
}
|
||||
const doc = this.host?.std.workspace.getDoc(this.docId$.value);
|
||||
const root = doc?.root as RootBlockModel;
|
||||
return root.title;
|
||||
});
|
||||
}
|
||||
|
||||
export class RichTextCell extends BaseRichTextCell {
|
||||
static override styles = css`
|
||||
affine-database-rich-text-cell {
|
||||
display: flex;
|
||||
@@ -101,39 +220,6 @@ export class RichTextCell extends BaseCellRenderer<Text> {
|
||||
}
|
||||
`;
|
||||
|
||||
get attributeRenderer() {
|
||||
return this.inlineManager?.getRenderer();
|
||||
}
|
||||
|
||||
get attributesSchema() {
|
||||
return this.inlineManager?.getSchema();
|
||||
}
|
||||
|
||||
get inlineEditor() {
|
||||
assertExists(this._richTextElement);
|
||||
const inlineEditor = this._richTextElement.inlineEditor;
|
||||
assertExists(inlineEditor);
|
||||
return inlineEditor;
|
||||
}
|
||||
|
||||
get inlineManager() {
|
||||
return this.view
|
||||
.contextGet(HostContextKey)
|
||||
?.std.get(DefaultInlineManagerExtension.identifier);
|
||||
}
|
||||
|
||||
get service() {
|
||||
return this.view
|
||||
.contextGet(HostContextKey)
|
||||
?.std.getService('affine:database');
|
||||
}
|
||||
|
||||
get topContenteditableElement() {
|
||||
const databaseBlock =
|
||||
this.closest<DatabaseBlockComponent>('affine-database');
|
||||
return databaseBlock?.topContenteditableElement;
|
||||
}
|
||||
|
||||
private changeUserSelectAccordToReadOnly() {
|
||||
if (this && this instanceof HTMLElement) {
|
||||
this.style.userSelect = this.readonly ? 'text' : 'none';
|
||||
@@ -163,18 +249,9 @@ export class RichTextCell extends BaseCellRenderer<Text> {
|
||||
></rich-text>`
|
||||
);
|
||||
}
|
||||
|
||||
override updated(changedProperties: PropertyValues) {
|
||||
if (changedProperties.has('readonly')) {
|
||||
this.changeUserSelectAccordToReadOnly();
|
||||
}
|
||||
}
|
||||
|
||||
@query('rich-text')
|
||||
private accessor _richTextElement: RichText | null = null;
|
||||
}
|
||||
|
||||
export class RichTextCellEditing extends BaseCellRenderer<Text> {
|
||||
export class RichTextCellEditing extends BaseRichTextCell {
|
||||
static override styles = css`
|
||||
affine-database-rich-text-cell-editing {
|
||||
display: flex;
|
||||
@@ -227,6 +304,7 @@ export class RichTextCellEditing extends BaseCellRenderer<Text> {
|
||||
}
|
||||
|
||||
const inlineEditor = this.inlineEditor;
|
||||
if (!inlineEditor) return;
|
||||
|
||||
switch (event.key) {
|
||||
// bold ctrl+b
|
||||
@@ -234,7 +312,7 @@ export class RichTextCellEditing extends BaseCellRenderer<Text> {
|
||||
case 'b':
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
event.preventDefault();
|
||||
toggleStyle(this.inlineEditor, { bold: true });
|
||||
toggleStyle(inlineEditor, { bold: true });
|
||||
}
|
||||
break;
|
||||
// italic ctrl+i
|
||||
@@ -242,7 +320,7 @@ export class RichTextCellEditing extends BaseCellRenderer<Text> {
|
||||
case 'i':
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
event.preventDefault();
|
||||
toggleStyle(this.inlineEditor, { italic: true });
|
||||
toggleStyle(inlineEditor, { italic: true });
|
||||
}
|
||||
break;
|
||||
// underline ctrl+u
|
||||
@@ -250,7 +328,7 @@ export class RichTextCellEditing extends BaseCellRenderer<Text> {
|
||||
case 'u':
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
event.preventDefault();
|
||||
toggleStyle(this.inlineEditor, { underline: true });
|
||||
toggleStyle(inlineEditor, { underline: true });
|
||||
}
|
||||
break;
|
||||
// strikethrough ctrl+shift+s
|
||||
@@ -293,42 +371,124 @@ export class RichTextCellEditing extends BaseCellRenderer<Text> {
|
||||
}
|
||||
};
|
||||
|
||||
get attributeRenderer() {
|
||||
return this.inlineManager?.getRenderer();
|
||||
}
|
||||
|
||||
get attributesSchema() {
|
||||
return this.inlineManager?.getSchema();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
get inlineEditor() {
|
||||
assertExists(this._richTextElement);
|
||||
const inlineEditor = this._richTextElement.inlineEditor;
|
||||
private readonly _onCopy = (e: ClipboardEvent) => {
|
||||
const inlineEditor = this.inlineEditor;
|
||||
assertExists(inlineEditor);
|
||||
return inlineEditor;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
get inlineManager() {
|
||||
return this.view
|
||||
.contextGet(HostContextKey)
|
||||
?.std.get(DefaultInlineManagerExtension.identifier);
|
||||
}
|
||||
const inlineRange = inlineEditor.getInlineRange();
|
||||
if (!inlineRange) return;
|
||||
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
get service() {
|
||||
return this.view
|
||||
.contextGet(HostContextKey)
|
||||
?.std.getService('affine:database');
|
||||
}
|
||||
const text = inlineEditor.yTextString.slice(
|
||||
inlineRange.index,
|
||||
inlineRange.index + inlineRange.length
|
||||
);
|
||||
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
get topContenteditableElement() {
|
||||
const databaseBlock =
|
||||
this.closest<DatabaseBlockComponent>('affine-database');
|
||||
return databaseBlock?.topContenteditableElement;
|
||||
}
|
||||
e.clipboardData?.setData('text/plain', text);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
private readonly _onCut = (e: ClipboardEvent) => {
|
||||
const inlineEditor = this.inlineEditor;
|
||||
assertExists(inlineEditor);
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
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();
|
||||
@@ -341,7 +501,7 @@ export class RichTextCellEditing extends BaseCellRenderer<Text> {
|
||||
if (e.key === 'a' && (IS_MAC ? e.metaKey : e.ctrlKey)) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
this.inlineEditor.selectAll();
|
||||
this.inlineEditor?.selectAll();
|
||||
}
|
||||
};
|
||||
this.addEventListener('keydown', selectAll);
|
||||
@@ -349,13 +509,32 @@ export class RichTextCellEditing extends BaseCellRenderer<Text> {
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
this._richTextElement?.updateComplete
|
||||
this.richText?.updateComplete
|
||||
.then(() => {
|
||||
const inlineEditor = this.inlineEditor;
|
||||
if (!inlineEditor) return;
|
||||
|
||||
this.disposables.add(
|
||||
this.inlineEditor.slots.keydown.on(this._handleKeyDown)
|
||||
inlineEditor.slots.keydown.on(this._handleKeyDown)
|
||||
);
|
||||
|
||||
this.inlineEditor.focusEnd();
|
||||
this.disposables.addFromEvent(
|
||||
this._richTextElement!,
|
||||
'copy',
|
||||
this._onCopy
|
||||
);
|
||||
this.disposables.addFromEvent(
|
||||
this._richTextElement!,
|
||||
'cut',
|
||||
this._onCut
|
||||
);
|
||||
this.disposables.addFromEvent(
|
||||
this._richTextElement!,
|
||||
'paste',
|
||||
this._onPaste
|
||||
);
|
||||
|
||||
inlineEditor.focusEnd();
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
@@ -377,8 +556,22 @@ export class RichTextCellEditing extends BaseCellRenderer<Text> {
|
||||
></rich-text>`;
|
||||
}
|
||||
|
||||
@query('rich-text')
|
||||
private accessor _richTextElement: RichText | null = null;
|
||||
private get std() {
|
||||
return this.view.contextGet(HostContextKey)?.std;
|
||||
}
|
||||
|
||||
insertDelta = (delta: DeltaInsert<AffineTextAttributes>) => {
|
||||
const inlineEditor = this.inlineEditor;
|
||||
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 {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import { propertyType, t } from '@blocksuite/data-view';
|
||||
import type { DeltaInsert } from '@blocksuite/inline';
|
||||
import { Text } from '@blocksuite/store';
|
||||
|
||||
import { HostContextKey } from '../../context/host-context.js';
|
||||
import { isLinkedDoc } from '../../utils/title-doc.js';
|
||||
import { type RichTextCellType, toYText } from '../utils.js';
|
||||
|
||||
export const richTextColumnType = propertyType('rich-text');
|
||||
@@ -16,7 +20,25 @@ export const richTextColumnModelConfig =
|
||||
value: new Text(value),
|
||||
};
|
||||
},
|
||||
cellToJson: ({ value }) => value?.toString() ?? null,
|
||||
cellToJson: ({ value, dataSource }) => {
|
||||
const host = dataSource.contextGet(HostContextKey);
|
||||
if (host) {
|
||||
const collection = host.std.workspace;
|
||||
const yText = toYText(value);
|
||||
const deltas = yText.toDelta();
|
||||
const text = deltas
|
||||
.map((delta: DeltaInsert<AffineTextAttributes>) => {
|
||||
if (isLinkedDoc(delta)) {
|
||||
const linkedDocId = delta.attributes?.reference?.pageId as string;
|
||||
return collection.getDoc(linkedDocId)?.meta?.title;
|
||||
}
|
||||
return delta.insert;
|
||||
})
|
||||
.join('');
|
||||
return text;
|
||||
}
|
||||
return value?.toString() ?? null;
|
||||
},
|
||||
cellFromJson: ({ value }) =>
|
||||
typeof value !== 'string' ? undefined : new Text(value),
|
||||
onUpdate: ({ value, callback }) => {
|
||||
|
||||
Reference in New Issue
Block a user