mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-23 04:51:49 +08:00
feat(editor): support linked-doc in rich-text column (#9634)
close: BS-2345
This commit is contained in:
@@ -84,6 +84,3 @@ packages/frontend/core/public/static/templates
|
|||||||
# script
|
# script
|
||||||
af
|
af
|
||||||
af.cmd
|
af.cmd
|
||||||
|
|
||||||
# AI agent memories
|
|
||||||
memories.md
|
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import {
|
import type {
|
||||||
type AffineInlineEditor,
|
AffineInlineEditor,
|
||||||
DefaultInlineManagerExtension,
|
RichText,
|
||||||
type RichText,
|
|
||||||
} from '@blocksuite/affine-components/rich-text';
|
} 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 type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||||
import { getViewportElement } from '@blocksuite/affine-shared/utils';
|
import {
|
||||||
|
getViewportElement,
|
||||||
|
isValidUrl,
|
||||||
|
} from '@blocksuite/affine-shared/utils';
|
||||||
import {
|
import {
|
||||||
BaseCellRenderer,
|
BaseCellRenderer,
|
||||||
createFromBaseCellRenderer,
|
createFromBaseCellRenderer,
|
||||||
@@ -12,8 +20,11 @@ import {
|
|||||||
} from '@blocksuite/data-view';
|
} from '@blocksuite/data-view';
|
||||||
import { IS_MAC } from '@blocksuite/global/env';
|
import { IS_MAC } from '@blocksuite/global/env';
|
||||||
import { assertExists } from '@blocksuite/global/utils';
|
import { assertExists } from '@blocksuite/global/utils';
|
||||||
|
import type { DeltaInsert } from '@blocksuite/inline';
|
||||||
|
import type { BlockSnapshot } from '@blocksuite/store';
|
||||||
import { Text } 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 { query } from 'lit/decorators.js';
|
||||||
import { keyed } from 'lit/directives/keyed.js';
|
import { keyed } from 'lit/directives/keyed.js';
|
||||||
import { html } from 'lit/static-html.js';
|
import { html } from 'lit/static-html.js';
|
||||||
@@ -23,9 +34,11 @@ import type { DatabaseBlockComponent } from '../../database-block.js';
|
|||||||
import { richTextColumnModelConfig } from './define.js';
|
import { richTextColumnModelConfig } from './define.js';
|
||||||
|
|
||||||
function toggleStyle(
|
function toggleStyle(
|
||||||
inlineEditor: AffineInlineEditor,
|
inlineEditor: AffineInlineEditor | null,
|
||||||
attrs: AffineTextAttributes
|
attrs: AffineTextAttributes
|
||||||
): void {
|
): void {
|
||||||
|
if (!inlineEditor) return;
|
||||||
|
|
||||||
const inlineRange = inlineEditor.getInlineRange();
|
const inlineRange = inlineEditor.getInlineRange();
|
||||||
if (!inlineRange) return;
|
if (!inlineRange) return;
|
||||||
|
|
||||||
@@ -68,7 +81,113 @@ function toggleStyle(
|
|||||||
inlineEditor.syncInlineRange();
|
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`
|
static override styles = css`
|
||||||
affine-database-rich-text-cell {
|
affine-database-rich-text-cell {
|
||||||
display: flex;
|
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() {
|
private changeUserSelectAccordToReadOnly() {
|
||||||
if (this && this instanceof HTMLElement) {
|
if (this && this instanceof HTMLElement) {
|
||||||
this.style.userSelect = this.readonly ? 'text' : 'none';
|
this.style.userSelect = this.readonly ? 'text' : 'none';
|
||||||
@@ -163,18 +249,9 @@ export class RichTextCell extends BaseCellRenderer<Text> {
|
|||||||
></rich-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`
|
static override styles = css`
|
||||||
affine-database-rich-text-cell-editing {
|
affine-database-rich-text-cell-editing {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -227,6 +304,7 @@ export class RichTextCellEditing extends BaseCellRenderer<Text> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const inlineEditor = this.inlineEditor;
|
const inlineEditor = this.inlineEditor;
|
||||||
|
if (!inlineEditor) return;
|
||||||
|
|
||||||
switch (event.key) {
|
switch (event.key) {
|
||||||
// bold ctrl+b
|
// bold ctrl+b
|
||||||
@@ -234,7 +312,7 @@ export class RichTextCellEditing extends BaseCellRenderer<Text> {
|
|||||||
case 'b':
|
case 'b':
|
||||||
if (event.metaKey || event.ctrlKey) {
|
if (event.metaKey || event.ctrlKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
toggleStyle(this.inlineEditor, { bold: true });
|
toggleStyle(inlineEditor, { bold: true });
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
// italic ctrl+i
|
// italic ctrl+i
|
||||||
@@ -242,7 +320,7 @@ export class RichTextCellEditing extends BaseCellRenderer<Text> {
|
|||||||
case 'i':
|
case 'i':
|
||||||
if (event.metaKey || event.ctrlKey) {
|
if (event.metaKey || event.ctrlKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
toggleStyle(this.inlineEditor, { italic: true });
|
toggleStyle(inlineEditor, { italic: true });
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
// underline ctrl+u
|
// underline ctrl+u
|
||||||
@@ -250,7 +328,7 @@ export class RichTextCellEditing extends BaseCellRenderer<Text> {
|
|||||||
case 'u':
|
case 'u':
|
||||||
if (event.metaKey || event.ctrlKey) {
|
if (event.metaKey || event.ctrlKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
toggleStyle(this.inlineEditor, { underline: true });
|
toggleStyle(inlineEditor, { underline: true });
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
// strikethrough ctrl+shift+s
|
// strikethrough ctrl+shift+s
|
||||||
@@ -293,42 +371,124 @@ export class RichTextCellEditing extends BaseCellRenderer<Text> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
get attributeRenderer() {
|
private readonly _onCopy = (e: ClipboardEvent) => {
|
||||||
return this.inlineManager?.getRenderer();
|
const inlineEditor = this.inlineEditor;
|
||||||
}
|
|
||||||
|
|
||||||
get attributesSchema() {
|
|
||||||
return this.inlineManager?.getSchema();
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
|
||||||
get inlineEditor() {
|
|
||||||
assertExists(this._richTextElement);
|
|
||||||
const inlineEditor = this._richTextElement.inlineEditor;
|
|
||||||
assertExists(inlineEditor);
|
assertExists(inlineEditor);
|
||||||
return inlineEditor;
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
const inlineRange = inlineEditor.getInlineRange();
|
||||||
get inlineManager() {
|
if (!inlineRange) return;
|
||||||
return this.view
|
|
||||||
.contextGet(HostContextKey)
|
|
||||||
?.std.get(DefaultInlineManagerExtension.identifier);
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
const text = inlineEditor.yTextString.slice(
|
||||||
get service() {
|
inlineRange.index,
|
||||||
return this.view
|
inlineRange.index + inlineRange.length
|
||||||
.contextGet(HostContextKey)
|
);
|
||||||
?.std.getService('affine:database');
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
e.clipboardData?.setData('text/plain', text);
|
||||||
get topContenteditableElement() {
|
e.preventDefault();
|
||||||
const databaseBlock =
|
e.stopPropagation();
|
||||||
this.closest<DatabaseBlockComponent>('affine-database');
|
};
|
||||||
return databaseBlock?.topContenteditableElement;
|
|
||||||
}
|
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() {
|
override connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
@@ -341,7 +501,7 @@ export class RichTextCellEditing extends BaseCellRenderer<Text> {
|
|||||||
if (e.key === 'a' && (IS_MAC ? e.metaKey : e.ctrlKey)) {
|
if (e.key === 'a' && (IS_MAC ? e.metaKey : e.ctrlKey)) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.inlineEditor.selectAll();
|
this.inlineEditor?.selectAll();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
this.addEventListener('keydown', selectAll);
|
this.addEventListener('keydown', selectAll);
|
||||||
@@ -349,13 +509,32 @@ export class RichTextCellEditing extends BaseCellRenderer<Text> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override firstUpdated() {
|
override firstUpdated() {
|
||||||
this._richTextElement?.updateComplete
|
this.richText?.updateComplete
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
const inlineEditor = this.inlineEditor;
|
||||||
|
if (!inlineEditor) return;
|
||||||
|
|
||||||
this.disposables.add(
|
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);
|
.catch(console.error);
|
||||||
}
|
}
|
||||||
@@ -377,8 +556,22 @@ export class RichTextCellEditing extends BaseCellRenderer<Text> {
|
|||||||
></rich-text>`;
|
></rich-text>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@query('rich-text')
|
private get std() {
|
||||||
private accessor _richTextElement: RichText | null = null;
|
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 {
|
declare global {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||||
import { propertyType, t } from '@blocksuite/data-view';
|
import { propertyType, t } from '@blocksuite/data-view';
|
||||||
|
import type { DeltaInsert } from '@blocksuite/inline';
|
||||||
import { Text } from '@blocksuite/store';
|
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';
|
import { type RichTextCellType, toYText } from '../utils.js';
|
||||||
|
|
||||||
export const richTextColumnType = propertyType('rich-text');
|
export const richTextColumnType = propertyType('rich-text');
|
||||||
@@ -16,7 +20,25 @@ export const richTextColumnModelConfig =
|
|||||||
value: new Text(value),
|
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 }) =>
|
cellFromJson: ({ value }) =>
|
||||||
typeof value !== 'string' ? undefined : new Text(value),
|
typeof value !== 'string' ? undefined : new Text(value),
|
||||||
onUpdate: ({ value, callback }) => {
|
onUpdate: ({ value, callback }) => {
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { test } from '@affine-test/kit/playwright';
|
import { test } from '@affine-test/kit/playwright';
|
||||||
import { openHomePage } from '@affine-test/kit/utils/load-page';
|
|
||||||
import { waitForEditorLoad } from '@affine-test/kit/utils/page-logic';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
initDatabaseWithRows,
|
addRows,
|
||||||
pasteExcelData,
|
initDatabaseByOneStep,
|
||||||
selectFirstCell,
|
pasteString,
|
||||||
|
selectCell,
|
||||||
verifyCellContents,
|
verifyCellContents,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
|
|
||||||
@@ -14,16 +13,14 @@ test.describe('Database Clipboard Operations', () => {
|
|||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
// Open the home page and wait for the editor to load
|
// Open the home page and wait for the editor to load
|
||||||
await openHomePage(page);
|
await initDatabaseByOneStep(page);
|
||||||
await waitForEditorLoad(page);
|
|
||||||
|
|
||||||
// Create a database block with two rows
|
// Create a database block with two rows
|
||||||
await initDatabaseWithRows(page, 2);
|
await addRows(page, 2);
|
||||||
|
|
||||||
// Select the first cell and paste data
|
// Select the first cell and paste data
|
||||||
await selectFirstCell(page);
|
await selectCell(page, 0, false);
|
||||||
const mockExcelData = 'Cell 1A\tCell 1B\nCell 2A\tCell 2B';
|
const mockExcelData = 'Cell 1A\tCell 1B\nCell 2A\tCell 2B';
|
||||||
await pasteExcelData(page, mockExcelData);
|
await pasteString(page, mockExcelData);
|
||||||
|
|
||||||
// Verify cell contents
|
// Verify cell contents
|
||||||
await verifyCellContents(page, [
|
await verifyCellContents(page, [
|
||||||
@@ -38,16 +35,14 @@ test.describe('Database Clipboard Operations', () => {
|
|||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
// Open the home page and wait for the editor to load
|
// Open the home page and wait for the editor to load
|
||||||
await openHomePage(page);
|
await initDatabaseByOneStep(page);
|
||||||
await waitForEditorLoad(page);
|
|
||||||
|
|
||||||
// Create a database block with two rows
|
// Create a database block with two rows
|
||||||
await initDatabaseWithRows(page, 2);
|
await addRows(page, 2);
|
||||||
|
|
||||||
// Select the first cell and paste data with empty cells
|
// Select the first cell and paste data with empty cells
|
||||||
await selectFirstCell(page);
|
await selectCell(page, 0, false);
|
||||||
const mockExcelData = 'Cell 1A\t\nCell 2A\tCell 2B';
|
const mockExcelData = 'Cell 1A\t\nCell 2A\tCell 2B';
|
||||||
await pasteExcelData(page, mockExcelData);
|
await pasteString(page, mockExcelData);
|
||||||
|
|
||||||
// Verify cell contents including empty cells
|
// Verify cell contents including empty cells
|
||||||
await verifyCellContents(page, ['Cell 1A', '', 'Cell 2A', 'Cell 2B']);
|
await verifyCellContents(page, ['Cell 1A', '', 'Cell 2A', 'Cell 2B']);
|
||||||
@@ -55,16 +50,14 @@ test.describe('Database Clipboard Operations', () => {
|
|||||||
|
|
||||||
test('handle pasting data larger than selected area', async ({ page }) => {
|
test('handle pasting data larger than selected area', async ({ page }) => {
|
||||||
// Open the home page and wait for the editor to load
|
// Open the home page and wait for the editor to load
|
||||||
await openHomePage(page);
|
await initDatabaseByOneStep(page);
|
||||||
await waitForEditorLoad(page);
|
|
||||||
|
|
||||||
// Create a database block with one row
|
// Create a database block with one row
|
||||||
await initDatabaseWithRows(page, 1);
|
await addRows(page, 1);
|
||||||
|
|
||||||
// Select the first cell and paste data larger than table
|
// Select the first cell and paste data larger than table
|
||||||
await selectFirstCell(page);
|
await selectCell(page, 0, false);
|
||||||
const mockExcelData = 'Cell 1A\tCell 1B\nCell 2A\tCell 2B';
|
const mockExcelData = 'Cell 1A\tCell 1B\nCell 2A\tCell 2B';
|
||||||
await pasteExcelData(page, mockExcelData);
|
await pasteString(page, mockExcelData);
|
||||||
|
|
||||||
// Verify only the cells that exist are filled
|
// Verify only the cells that exist are filled
|
||||||
await verifyCellContents(page, ['Cell 1A', 'Cell 1B']);
|
await verifyCellContents(page, ['Cell 1A', 'Cell 1B']);
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { test } from '@affine-test/kit/playwright';
|
||||||
|
import { openHomePage } from '@affine-test/kit/utils/load-page';
|
||||||
|
import {
|
||||||
|
clickNewPageButton,
|
||||||
|
waitForEditorLoad,
|
||||||
|
} from '@affine-test/kit/utils/page-logic';
|
||||||
|
import { expect } from '@playwright/test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
addColumn,
|
||||||
|
createDatabaseBlock,
|
||||||
|
createNewPage,
|
||||||
|
gotoContentFromTitle,
|
||||||
|
selectCell,
|
||||||
|
} from './utils';
|
||||||
|
|
||||||
|
test.describe('Database Rich Text Column', () => {
|
||||||
|
test('paste document link into rich text cell', async ({ page }) => {
|
||||||
|
// Step 1: Open home page
|
||||||
|
await openHomePage(page);
|
||||||
|
await waitForEditorLoad(page);
|
||||||
|
|
||||||
|
// Step 2: Create a new page
|
||||||
|
await createNewPage(page);
|
||||||
|
|
||||||
|
await gotoContentFromTitle(page);
|
||||||
|
|
||||||
|
// Step 3: Create a database in the page
|
||||||
|
await createDatabaseBlock(page);
|
||||||
|
|
||||||
|
// Step 4: Add a text column
|
||||||
|
await addColumn(page, 'Text');
|
||||||
|
|
||||||
|
// Step 5: Create a new page to get its link
|
||||||
|
await clickNewPageButton(page, 'Test Page');
|
||||||
|
const pageUrl = page.url();
|
||||||
|
|
||||||
|
// Step 6: Go back to database page
|
||||||
|
await page.goBack();
|
||||||
|
await waitForEditorLoad(page);
|
||||||
|
|
||||||
|
// Step 7: Select and edit the rich text cell
|
||||||
|
const richTextCell = await selectCell(page, 2);
|
||||||
|
|
||||||
|
// Step 8: Paste the document link
|
||||||
|
await page.evaluate(url => {
|
||||||
|
const clipboardData = new DataTransfer();
|
||||||
|
clipboardData.setData('text/plain', url);
|
||||||
|
const pasteEvent = new ClipboardEvent('paste', {
|
||||||
|
clipboardData,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
});
|
||||||
|
document.activeElement?.dispatchEvent(pasteEvent);
|
||||||
|
}, pageUrl);
|
||||||
|
|
||||||
|
// Step 9: Verify the result
|
||||||
|
const referenceTitle = richTextCell.locator('.affine-reference-title');
|
||||||
|
await expect(referenceTitle).toBeVisible();
|
||||||
|
await expect(referenceTitle).toContainText('Test Page');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('add document link via @ in rich text cell', async ({ page }) => {
|
||||||
|
// Step 1: Open home page
|
||||||
|
await openHomePage(page);
|
||||||
|
await waitForEditorLoad(page);
|
||||||
|
|
||||||
|
// Step 2: Create a new page
|
||||||
|
await createNewPage(page);
|
||||||
|
await gotoContentFromTitle(page);
|
||||||
|
|
||||||
|
// Step 3: Create a database in the page
|
||||||
|
await createDatabaseBlock(page);
|
||||||
|
|
||||||
|
// Step 4: Add a text column
|
||||||
|
await addColumn(page, 'Text');
|
||||||
|
|
||||||
|
// Step 5: Create a new page as reference target
|
||||||
|
await clickNewPageButton(page, 'Reference Target');
|
||||||
|
await page.goBack();
|
||||||
|
await waitForEditorLoad(page);
|
||||||
|
|
||||||
|
// Step 6: Select and edit the rich text cell
|
||||||
|
const richTextCell = await selectCell(page, 2);
|
||||||
|
await richTextCell.click();
|
||||||
|
await page.keyboard.type('@');
|
||||||
|
|
||||||
|
// Step 7: Wait for reference picker and select the page
|
||||||
|
const linkedDocPopover = page.locator('.linked-doc-popover');
|
||||||
|
await expect(linkedDocPopover).toBeVisible();
|
||||||
|
const targetPage = linkedDocPopover.getByText('Reference Target');
|
||||||
|
await targetPage.click();
|
||||||
|
|
||||||
|
// Step 8: Verify the result
|
||||||
|
const referenceTitle = richTextCell.locator('.affine-reference-title');
|
||||||
|
await expect(referenceTitle).toBeVisible();
|
||||||
|
await expect(referenceTitle).toContainText('Reference Target');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,45 +1,36 @@
|
|||||||
|
import { openHomePage } from '@affine-test/kit/utils/load-page';
|
||||||
import {
|
import {
|
||||||
addDatabase,
|
addDatabase,
|
||||||
clickNewPageButton,
|
clickNewPageButton,
|
||||||
|
waitForEditorLoad,
|
||||||
} from '@affine-test/kit/utils/page-logic';
|
} from '@affine-test/kit/utils/page-logic';
|
||||||
import type { Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
import { expect } from '@playwright/test';
|
import { expect } from '@playwright/test';
|
||||||
|
|
||||||
/**
|
export async function createNewPage(page: Page) {
|
||||||
* Create a new database block in the current page
|
|
||||||
*/
|
|
||||||
export async function createDatabaseBlock(page: Page) {
|
|
||||||
await clickNewPageButton(page);
|
await clickNewPageButton(page);
|
||||||
await page.waitForTimeout(500);
|
}
|
||||||
|
|
||||||
|
export const gotoContentFromTitle = async (page: Page) => {
|
||||||
await page.keyboard.press('Enter');
|
await page.keyboard.press('Enter');
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createDatabaseBlock(page: Page) {
|
||||||
await addDatabase(page);
|
await addDatabase(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function addRows(page: Page, rowCount: number) {
|
||||||
* Initialize a database with specified number of rows
|
|
||||||
*/
|
|
||||||
export async function initDatabaseWithRows(page: Page, rowCount: number) {
|
|
||||||
await createDatabaseBlock(page);
|
|
||||||
for (let i = 0; i < rowCount; i++) {
|
for (let i = 0; i < rowCount; i++) {
|
||||||
await addDatabaseRow(page);
|
await addDatabaseRow(page);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a new row to the database
|
|
||||||
*/
|
|
||||||
export async function addDatabaseRow(page: Page) {
|
export async function addDatabaseRow(page: Page) {
|
||||||
const addButton = page.locator('.data-view-table-group-add-row');
|
const addButton = page.locator('.data-view-table-group-add-row');
|
||||||
await addButton.waitFor();
|
|
||||||
await addButton.click();
|
await addButton.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function pasteString(page: Page, data: string) {
|
||||||
* Simulate pasting Excel data into database
|
|
||||||
* @param page Playwright page object
|
|
||||||
* @param data Tab-separated text data with newlines for rows
|
|
||||||
*/
|
|
||||||
export async function pasteExcelData(page: Page, data: string) {
|
|
||||||
await page.evaluate(data => {
|
await page.evaluate(data => {
|
||||||
const clipboardData = new DataTransfer();
|
const clipboardData = new DataTransfer();
|
||||||
clipboardData.setData('text/plain', data);
|
clipboardData.setData('text/plain', data);
|
||||||
@@ -48,24 +39,25 @@ export async function pasteExcelData(page: Page, data: string) {
|
|||||||
bubbles: true,
|
bubbles: true,
|
||||||
cancelable: true,
|
cancelable: true,
|
||||||
});
|
});
|
||||||
document.activeElement?.dispatchEvent(pasteEvent);
|
const activeElement = document.activeElement;
|
||||||
|
if (activeElement) {
|
||||||
|
pasteEvent.preventDefault();
|
||||||
|
activeElement.dispatchEvent(pasteEvent);
|
||||||
|
}
|
||||||
}, data);
|
}, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function selectCell(page: Page, nth: number, editing = true) {
|
||||||
* Select the first cell in the database
|
const firstCell = page.locator('affine-database-cell-container').nth(nth);
|
||||||
*/
|
// First click for focus
|
||||||
export async function selectFirstCell(page: Page) {
|
await firstCell.click({ delay: 100 });
|
||||||
const firstCell = page.locator('affine-database-cell-container').first();
|
// Second click for edit mode
|
||||||
await firstCell.waitFor();
|
if (editing) {
|
||||||
await firstCell.click();
|
await firstCell.click({ delay: 100 });
|
||||||
|
}
|
||||||
|
return firstCell;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify the contents of multiple cells in sequence
|
|
||||||
* @param page Playwright page object
|
|
||||||
* @param expectedContents Array of expected cell contents in order
|
|
||||||
*/
|
|
||||||
export async function verifyCellContents(
|
export async function verifyCellContents(
|
||||||
page: Page,
|
page: Page,
|
||||||
expectedContents: string[]
|
expectedContents: string[]
|
||||||
@@ -78,3 +70,43 @@ export async function verifyCellContents(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function selectColumnType(page: Page, columnType: string) {
|
||||||
|
const typeMenu = page.locator('affine-menu').getByText('Type');
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
await typeMenu.hover();
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
await page.keyboard.type(columnType);
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
await page.keyboard.press('ArrowDown');
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addColumn(page: Page, type: string) {
|
||||||
|
await clickAddColumnButton(page);
|
||||||
|
await selectColumnType(page, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clickAddColumnButton(page: Page) {
|
||||||
|
const addColumnButton = page.locator('.header-add-column-button');
|
||||||
|
await addColumnButton.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function changeColumnType(
|
||||||
|
page: Page,
|
||||||
|
columnIndex: number,
|
||||||
|
columnType: string
|
||||||
|
) {
|
||||||
|
const header = page.locator('affine-database-header-column').nth(columnIndex);
|
||||||
|
await header.click();
|
||||||
|
await selectColumnType(page, columnType);
|
||||||
|
}
|
||||||
|
export const initDatabaseByOneStep = async (page: Page) => {
|
||||||
|
await openHomePage(page);
|
||||||
|
await createNewPage(page);
|
||||||
|
await waitForEditorLoad(page);
|
||||||
|
await gotoContentFromTitle(page);
|
||||||
|
await createDatabaseBlock(page);
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user