refactor(editor): unify directories naming (#11516)

**Directory Structure Changes**

- Renamed multiple block-related directories by removing the "block-" prefix:
  - `block-attachment` → `attachment`
  - `block-bookmark` → `bookmark`
  - `block-callout` → `callout`
  - `block-code` → `code`
  - `block-data-view` → `data-view`
  - `block-database` → `database`
  - `block-divider` → `divider`
  - `block-edgeless-text` → `edgeless-text`
  - `block-embed` → `embed`
This commit is contained in:
Saul-Mirone
2025-04-07 12:34:40 +00:00
parent e1bd2047c4
commit 1f45cc5dec
893 changed files with 439 additions and 460 deletions
@@ -0,0 +1,176 @@
import {
createPropertyConvert,
getTagColor,
type SelectTag,
} from '@blocksuite/data-view';
import { presetPropertyConverts } from '@blocksuite/data-view/property-presets';
import { propertyModelPresets } from '@blocksuite/data-view/property-pure-presets';
import { clamp } from '@blocksuite/global/gfx';
import { nanoid, Text } from '@blocksuite/store';
import { richTextPropertyModelConfig } from './rich-text/define.js';
export const databasePropertyConverts = [
...presetPropertyConverts,
createPropertyConvert(
richTextPropertyModelConfig,
propertyModelPresets.selectPropertyModelConfig,
(_property, cells) => {
const options: Record<string, SelectTag> = {};
const getTag = (name: string) => {
if (options[name]) return options[name];
const tag: SelectTag = {
id: nanoid(),
value: name,
color: getTagColor(),
};
options[name] = tag;
return tag;
};
return {
cells: cells.map(v => {
const tags = v?.toString().split(',');
const value = tags?.[0]?.trim();
if (value) {
return getTag(value).id;
}
return undefined;
}),
property: {
options: Object.values(options),
},
};
}
),
createPropertyConvert(
richTextPropertyModelConfig,
propertyModelPresets.multiSelectPropertyModelConfig,
(_property, cells) => {
const options: Record<string, SelectTag> = {};
// eslint-disable-next-line sonarjs/no-identical-functions
const getTag = (name: string) => {
if (options[name]) return options[name];
const tag: SelectTag = {
id: nanoid(),
value: name,
color: getTagColor(),
};
options[name] = tag;
return tag;
};
return {
cells: cells.map(v => {
const result: string[] = [];
const values = v?.toString().split(',');
values?.forEach(value => {
value = value.trim();
if (value) {
result.push(getTag(value).id);
}
});
return result;
}),
property: {
options: Object.values(options),
},
};
}
),
createPropertyConvert(
richTextPropertyModelConfig,
propertyModelPresets.numberPropertyModelConfig,
(_property, cells) => {
return {
property: {
decimal: 0,
format: 'number' as const,
},
cells: cells.map(v => {
const num = v ? parseFloat(v.toString()) : NaN;
return isNaN(num) ? undefined : num;
}),
};
}
),
createPropertyConvert(
richTextPropertyModelConfig,
propertyModelPresets.progressPropertyModelConfig,
(_property, cells) => {
return {
property: {},
cells: cells.map(v => {
const progress = v ? parseInt(v.toString()) : NaN;
return !isNaN(progress) ? clamp(progress, 0, 100) : undefined;
}),
};
}
),
createPropertyConvert(
richTextPropertyModelConfig,
propertyModelPresets.checkboxPropertyModelConfig,
(_property, cells) => {
const truthyValues = new Set(['yes', 'true']);
return {
property: {},
cells: cells.map(v =>
v && truthyValues.has(v.toString().toLowerCase()) ? true : undefined
),
};
}
),
createPropertyConvert(
propertyModelPresets.checkboxPropertyModelConfig,
richTextPropertyModelConfig,
(_property, cells) => {
return {
property: {},
cells: cells.map(v => new Text(v ? 'Yes' : 'No').yText),
};
}
),
createPropertyConvert(
propertyModelPresets.multiSelectPropertyModelConfig,
richTextPropertyModelConfig,
(property, cells) => {
const optionMap = Object.fromEntries(
property.options.map(v => [v.id, v])
);
return {
property: {},
cells: cells.map(
arr =>
new Text(arr?.map(v => optionMap[v]?.value ?? '').join(',')).yText
),
};
}
),
createPropertyConvert(
propertyModelPresets.numberPropertyModelConfig,
richTextPropertyModelConfig,
(_property, cells) => ({
property: {},
cells: cells.map(v => new Text(v?.toString()).yText),
})
),
createPropertyConvert(
propertyModelPresets.progressPropertyModelConfig,
richTextPropertyModelConfig,
(_property, cells) => ({
property: {},
cells: cells.map(v => new Text(v?.toString()).yText),
})
),
createPropertyConvert(
propertyModelPresets.selectPropertyModelConfig,
richTextPropertyModelConfig,
(property, cells) => {
const optionMap = Object.fromEntries(
property.options.map(v => [v.id, v])
);
return {
property: {},
cells: cells.map(v => new Text(v ? optionMap[v]?.value : '').yText),
};
}
),
];
@@ -0,0 +1,27 @@
import { propertyPresets } from '@blocksuite/data-view/property-presets';
import { linkColumnConfig } from './link/cell-renderer.js';
import { richTextColumnConfig } from './rich-text/cell-renderer.js';
import { titleColumnConfig } from './title/cell-renderer.js';
export * from './converts.js';
const {
checkboxPropertyConfig,
datePropertyConfig,
multiSelectPropertyConfig,
numberPropertyConfig,
progressPropertyConfig,
selectPropertyConfig,
} = propertyPresets;
export const databaseBlockProperties = {
checkboxColumnConfig: checkboxPropertyConfig,
dateColumnConfig: datePropertyConfig,
multiSelectColumnConfig: multiSelectPropertyConfig,
numberColumnConfig: numberPropertyConfig,
progressColumnConfig: progressPropertyConfig,
selectColumnConfig: selectPropertyConfig,
imageColumnConfig: propertyPresets.imagePropertyConfig,
linkColumnConfig,
richTextColumnConfig,
titleColumnConfig,
};
@@ -0,0 +1,99 @@
import { cssVarV2 } from '@blocksuite/affine-shared/theme';
import { baseTheme } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const linkCellStyle = style({
width: '100%',
height: '100%',
userSelect: 'none',
position: 'relative',
});
export const linkContainerStyle = style({
display: 'flex',
position: 'relative',
alignItems: 'center',
width: '100%',
height: '100%',
outline: 'none',
overflow: 'hidden',
fontSize: 'var(--data-view-cell-text-size)',
lineHeight: 'var(--data-view-cell-text-line-height)',
wordBreak: 'break-all',
});
export const linkIconContainerStyle = style({
position: 'absolute',
right: '8px',
top: '8px',
display: 'flex',
alignItems: 'center',
visibility: 'hidden',
backgroundColor: cssVarV2.layer.background.primary,
boxShadow: 'var(--affine-button-shadow)',
borderRadius: '4px',
overflow: 'hidden',
zIndex: 1,
});
export const linkIconStyle = style({
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
color: cssVarV2.icon.primary,
fontSize: '14px',
padding: '2px',
':hover': {
backgroundColor: cssVarV2.layer.background.hoverOverlay,
},
});
export const showLinkIconStyle = style({
selectors: {
[`${linkCellStyle}:hover &`]: {
visibility: 'visible',
},
},
});
export const linkedDocStyle = style({
textDecoration: 'underline',
textDecorationColor: 'var(--affine-divider-color)',
transition: 'text-decoration-color 0.2s ease-out',
cursor: 'pointer',
':hover': {
textDecorationColor: 'var(--affine-icon-color)',
},
});
export const linkEditingStyle = style({
display: 'flex',
alignItems: 'center',
width: '100%',
padding: '0',
border: 'none',
fontFamily: baseTheme.fontSansFamily,
color: 'var(--affine-text-primary-color)',
fontWeight: '400',
backgroundColor: 'transparent',
fontSize: 'var(--data-view-cell-text-size)',
lineHeight: 'var(--data-view-cell-text-line-height)',
wordBreak: 'break-all',
':focus': {
outline: 'none',
},
});
export const inlineLinkNodeStyle = style({
wordBreak: 'break-all',
color: 'var(--affine-link-color)',
fill: 'var(--affine-link-color)',
cursor: 'pointer',
fontWeight: 'normal',
fontStyle: 'normal',
textDecoration: 'none',
});
export const normalTextStyle = style({
wordBreak: 'break-all',
});
@@ -0,0 +1,186 @@
import { RefNodeSlotsProvider } from '@blocksuite/affine-inline-reference';
import { ParseDocUrlProvider } from '@blocksuite/affine-shared/services';
import {
isValidUrl,
normalizeUrl,
stopPropagation,
} from '@blocksuite/affine-shared/utils';
import {
BaseCellRenderer,
createFromBaseCellRenderer,
createIcon,
} from '@blocksuite/data-view';
import { EditIcon } from '@blocksuite/icons/lit';
import { computed } from '@preact/signals-core';
import { html, nothing, type PropertyValues } from 'lit';
import { createRef, ref } from 'lit/directives/ref.js';
import { HostContextKey } from '../../context/host-context.js';
import {
inlineLinkNodeStyle,
linkCellStyle,
linkContainerStyle,
linkedDocStyle,
linkEditingStyle,
linkIconContainerStyle,
linkIconStyle,
normalTextStyle,
showLinkIconStyle,
} from './cell-renderer.css.js';
import { linkPropertyModelConfig } from './define.js';
export class LinkCell extends BaseCellRenderer<string, string> {
protected override firstUpdated(_changedProperties: PropertyValues) {
super.firstUpdated(_changedProperties);
this.classList.add(linkCellStyle);
}
private readonly _onEdit = (e: Event) => {
e.stopPropagation();
this.selectCurrentCell(true);
this.selectCurrentCell(true);
};
private readonly _focusEnd = () => {
const ele = this._container.value;
if (!ele) {
return;
}
const end = ele?.value.length;
ele?.focus();
ele?.setSelectionRange(end, end);
};
private readonly _onKeydown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.isComposing) {
this.selectCurrentCell(false);
}
};
private readonly _setValue = (
value: string = this._container.value?.value ?? ''
) => {
let url = value;
if (isValidUrl(value)) {
url = normalizeUrl(value);
}
this.valueSetNextTick(url);
if (this._container.value) {
this._container.value.value = url;
}
};
openDoc = (e: MouseEvent) => {
e.stopPropagation();
if (!this.docId$.value) {
return;
}
const std = this.std;
if (!std) {
return;
}
std.getOptional(RefNodeSlotsProvider)?.docLinkClicked.next({
pageId: this.docId$.value,
host: std.host,
});
};
get std() {
const host = this.view.contextGet(HostContextKey);
return host?.std;
}
docId$ = computed(() => {
if (!this.value || !isValidUrl(this.value)) {
return;
}
return this.parseDocUrl(this.value)?.docId;
});
private readonly _container = createRef<HTMLInputElement>();
override afterEnterEditingMode() {
this._focusEnd();
}
override beforeExitEditingMode() {
this._setValue();
}
parseDocUrl(url: string) {
return this.std?.getOptional(ParseDocUrlProvider)?.parseDocUrl(url);
}
docName$ = computed(() => {
const title =
this.docId$.value &&
this.std?.workspace.getDoc(this.docId$.value)?.meta?.title;
if (title == null) {
return;
}
return title || 'Untitled';
});
renderLink() {
const linkText = this.value ?? '';
const docName = this.docName$.value;
const isDoc = !!docName;
const isLink = !!linkText;
const hasLink = isDoc || isLink;
return html`
<div>
<div class="${linkContainerStyle}">
${isDoc
? html`<span class="${linkedDocStyle}" @click="${this.openDoc}"
>${docName}</span
>`
: isValidUrl(linkText)
? html`<a
data-testid="property-link-a"
class="${inlineLinkNodeStyle}"
href="${linkText}"
rel="noopener noreferrer"
target="_blank"
>${linkText}</a
>`
: html`<span class="${normalTextStyle}">${linkText}</span>`}
</div>
${hasLink
? html` <div class="${linkIconContainerStyle} ${showLinkIconStyle}">
<div
class="${linkIconStyle}"
data-testid="edit-link-button"
@click="${this._onEdit}"
>
${EditIcon()}
</div>
</div>`
: nothing}
</div>
`;
}
override render() {
if (this.isEditing$.value) {
const linkText = this.value ?? '';
return html`<input
class="${linkEditingStyle} link"
${ref(this._container)}
.value="${linkText}"
@keydown="${this._onKeydown}"
@pointerdown="${stopPropagation}"
/>`;
} else {
return this.renderLink();
}
}
}
export const linkColumnConfig = linkPropertyModelConfig.createPropertyMeta({
icon: createIcon('LinkIcon'),
cellRenderer: {
view: createFromBaseCellRenderer(LinkCell),
},
});
@@ -0,0 +1,25 @@
import { propertyType, t } from '@blocksuite/data-view';
import zod from 'zod';
export const linkColumnType = propertyType('link');
export const linkPropertyModelConfig = linkColumnType.modelConfig({
name: 'Link',
propertyData: {
schema: zod.object({}),
default: () => ({}),
},
jsonValue: {
schema: zod.string(),
type: () => t.string.instance(),
isEmpty: ({ value }) => !value,
},
rawValue: {
schema: zod.string(),
default: () => '',
toString: ({ value }) => value,
fromString: ({ value }) => {
return { value: value };
},
toJson: ({ value }) => value,
fromJson: ({ value }) => value,
},
});
@@ -0,0 +1,20 @@
import type { PropertyModel } from '@blocksuite/data-view';
import { propertyModelPresets } from '@blocksuite/data-view/property-pure-presets';
import { linkPropertyModelConfig } from './link/define';
import { richTextPropertyModelConfig } from './rich-text/define';
import { titlePropertyModelConfig } from './title/define';
export const databaseBlockModels = Object.fromEntries(
[
propertyModelPresets.checkboxPropertyModelConfig,
propertyModelPresets.datePropertyModelConfig,
propertyModelPresets.numberPropertyModelConfig,
propertyModelPresets.progressPropertyModelConfig,
propertyModelPresets.selectPropertyModelConfig,
propertyModelPresets.multiSelectPropertyModelConfig,
linkPropertyModelConfig,
richTextPropertyModelConfig,
titlePropertyModelConfig,
].map(v => [v.type, v as PropertyModel])
);
@@ -0,0 +1,20 @@
import { style } from '@vanilla-extract/css';
export const richTextCellStyle = style({
display: 'flex',
alignItems: 'center',
width: '100%',
userSelect: 'none',
});
export const richTextContainerStyle = style({
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
width: '100%',
height: '100%',
outline: 'none',
fontSize: 'var(--data-view-cell-text-size)',
lineHeight: 'var(--data-view-cell-text-line-height)',
wordBreak: 'break-all',
});
@@ -0,0 +1,432 @@
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 { HostContextKey } 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];
}
})
);
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
.contextGet(HostContextKey)
?.std.get(DefaultInlineManagerExtension.identifier);
}
get topContenteditableElement() {
const databaseBlock =
this.closest<DatabaseBlockComponent>('affine-database');
return databaseBlock?.topContenteditableElement;
}
get host() {
return this.view.contextGet(HostContextKey);
}
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 {
console.log(text);
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.addEventListener('keydown', 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.contextGet(HostContextKey)?.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),
},
});
@@ -0,0 +1,77 @@
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { propertyType, t } from '@blocksuite/data-view';
import type { DeltaInsert } from '@blocksuite/store';
import { Text } from '@blocksuite/store';
import * as Y from 'yjs';
import zod from 'zod';
import { HostContextKey } from '../../context/host-context.js';
import { isLinkedDoc } from '../../utils/title-doc.js';
export const richTextColumnType = propertyType('rich-text');
export type RichTextCellType = Text | Text['yText'];
export const toYText = (text?: RichTextCellType): undefined | Text['yText'] => {
if (text instanceof Text) {
return text.yText;
}
return text;
};
export const richTextPropertyModelConfig = richTextColumnType.modelConfig({
name: 'Text',
propertyData: {
schema: zod.object({}),
default: () => ({}),
},
jsonValue: {
schema: zod.string(),
type: () => t.richText.instance(),
isEmpty: ({ value }) => !value,
},
rawValue: {
schema: zod
.custom<RichTextCellType>(
data => data instanceof Text || data instanceof Y.Text
)
.optional(),
default: () => undefined,
toString: ({ value }) => value?.toString() ?? '',
fromString: ({ value }) => {
return {
value: new Text(value),
};
},
toJson: ({ value, dataSource }) => {
if (!value) return null;
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;
},
fromJson: ({ value }) =>
typeof value !== 'string' ? undefined : new Text(value),
onUpdate: ({ value, callback }) => {
const yText = toYText(value);
yText?.observe(callback);
callback();
return {
dispose: () => {
yText?.unobserve(callback);
},
};
},
},
});
@@ -0,0 +1,31 @@
import { cssVarV2 } from '@blocksuite/affine-shared/theme';
import { style } from '@vanilla-extract/css';
export const titleCellStyle = style({
width: '100%',
display: 'flex',
});
export const titleRichTextStyle = style({
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
width: '100%',
height: '100%',
outline: 'none',
wordBreak: 'break-all',
fontSize: 'var(--data-view-cell-text-size)',
lineHeight: 'var(--data-view-cell-text-line-height)',
});
export const headerAreaIconStyle = style({
height: 'max-content',
display: 'flex',
alignItems: 'center',
marginRight: '8px',
padding: '2px',
borderRadius: '4px',
marginTop: '2px',
color: cssVarV2.icon.primary,
backgroundColor: 'var(--affine-background-secondary-color)',
});
@@ -0,0 +1,23 @@
import {
type CellRenderProps,
createFromBaseCellRenderer,
createIcon,
uniMap,
} from '@blocksuite/data-view';
import { TableSingleView } from '@blocksuite/data-view/view-presets';
import { titlePropertyModelConfig } from './define.js';
import { HeaderAreaTextCell } from './text.js';
export const titleColumnConfig = titlePropertyModelConfig.createPropertyMeta({
icon: createIcon('TitleIcon'),
cellRenderer: {
view: uniMap(
createFromBaseCellRenderer(HeaderAreaTextCell),
(props: CellRenderProps) => ({
...props,
showIcon: props.cell.view instanceof TableSingleView,
})
),
},
});
@@ -0,0 +1,83 @@
import { propertyType, t } from '@blocksuite/data-view';
import { Text } from '@blocksuite/store';
import { Doc } from 'yjs';
import zod from 'zod';
import { HostContextKey } from '../../context/host-context.js';
import { isLinkedDoc } from '../../utils/title-doc.js';
export const titleColumnType = propertyType('title');
export const titlePropertyModelConfig = titleColumnType.modelConfig({
name: 'Title',
propertyData: {
schema: zod.object({}),
default: () => ({}),
},
jsonValue: {
schema: zod.string(),
type: () => t.richText.instance(),
isEmpty: ({ value }) => !value,
},
rawValue: {
schema: zod.custom<Text>(data => data instanceof Text).optional(),
default: () => undefined,
toString: ({ value }) => value?.toString() ?? '',
fromString: ({ value }) => {
return { value: new Text(value) };
},
toJson: ({ value, dataSource }) => {
if (!value) return '';
const host = dataSource.contextGet(HostContextKey);
if (host) {
const collection = host.std.workspace;
const deltas = value.deltas$.value;
const text = deltas
.map(delta => {
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() ?? '';
},
fromJson: ({ value }) => new Text(value),
onUpdate: ({ value, callback }) => {
value?.yText.observe(callback);
callback();
return {
dispose: () => {
value?.yText.unobserve(callback);
},
};
},
setValue: ({ value, newValue }) => {
if (value == null) {
return;
}
const v = newValue as unknown;
if (v == null) {
value.replace(0, value.length, '');
return;
}
if (typeof v === 'string') {
value.replace(0, value.length, v);
return;
}
if (newValue instanceof Text) {
new Doc().getMap('root').set('text', newValue.yText);
value.clear();
value.applyDelta(newValue.toDelta());
return;
}
},
},
fixed: {
defaultData: {},
defaultShow: true,
},
});
@@ -0,0 +1,21 @@
import { BaseCellRenderer } from '@blocksuite/data-view';
import { css, html } from 'lit';
export class IconCell extends BaseCellRenderer<string> {
static override styles = css`
affine-database-image-cell {
width: 100%;
height: 100%;
display: flex;
align-items: center;
}
affine-database-image-cell img {
width: 20px;
height: 20px;
}
`;
override render() {
return html`<img src=${this.value ?? ''}></img>`;
}
}
@@ -0,0 +1,293 @@
import { DefaultInlineManagerExtension } from '@blocksuite/affine-inline-preset';
import type { RichText } from '@blocksuite/affine-rich-text';
import {
ParseDocUrlProvider,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import {
getViewportElement,
isValidUrl,
} from '@blocksuite/affine-shared/utils';
import { BaseCellRenderer } from '@blocksuite/data-view';
import { IS_MAC } from '@blocksuite/global/env';
import { LinkedPageIcon } from '@blocksuite/icons/lit';
import type { BlockSnapshot, DeltaInsert, Text } from '@blocksuite/store';
import { signal } from '@preact/signals-core';
import { property } from 'lit/decorators.js';
import { createRef, ref } from 'lit/directives/ref.js';
import { html } from 'lit/static-html.js';
import { HostContextKey } from '../../context/host-context.js';
import type { DatabaseBlockComponent } from '../../database-block.js';
import { getSingleDocIdFromText } from '../../utils/title-doc.js';
import {
headerAreaIconStyle,
titleCellStyle,
titleRichTextStyle,
} from './cell-renderer.css.js';
export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
activity = true;
docId$ = signal<string>();
get host() {
return this.view.contextGet(HostContextKey);
}
get inlineEditor() {
return this.richText.value?.inlineEditor;
}
get inlineManager() {
return this.host?.std.get(DefaultInlineManagerExtension.identifier);
}
get topContenteditableElement() {
const databaseBlock =
this.closest<DatabaseBlockComponent>('affine-database');
return databaseBlock?.topContenteditableElement;
}
get std() {
return this.view.contextGet(HostContextKey)?.std;
}
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;
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 title column
std?.getOptional(TelemetryProvider)?.track('LinkedDocCreated', {
module: 'database title 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,
});
}
};
insertDelta = (delta: DeltaInsert) => {
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,
});
};
override connectedCallback() {
super.connectedCallback();
this.classList.add(titleCellStyle);
const yText = this.value?.yText;
if (yText) {
const cb = () => {
const id = getSingleDocIdFromText(this.value);
this.docId$.value = id;
};
cb();
if (this.activity) {
yText.observe(cb);
this.disposables.add(() => {
yText.unobserve(cb);
});
}
}
const selectAll = (e: KeyboardEvent) => {
if (e.key === 'a' && (IS_MAC ? e.metaKey : e.ctrlKey)) {
e.stopPropagation();
e.preventDefault();
this.inlineEditor?.selectAll();
}
};
this.addEventListener('keydown', selectAll);
this.disposables.addFromEvent(this, 'keydown', selectAll);
}
override firstUpdated(props: Map<string, unknown>) {
super.firstUpdated(props);
this.richText.value?.updateComplete
.then(() => {
this.disposables.addFromEvent(
this.richText.value,
'copy',
this._onCopy
);
this.disposables.addFromEvent(this.richText.value, 'cut', this._onCut);
this.disposables.addFromEvent(
this.richText.value,
'paste',
this._onPaste
);
})
.catch(console.error);
}
override afterEnterEditingMode() {
this.inlineEditor?.focusEnd();
}
protected override render(): unknown {
return html`${this.renderIcon()}${this.renderBlockText()}`;
}
renderBlockText() {
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}"
.enableClipboard="${false}"
.verticalScrollContainerGetter="${() =>
this.topContenteditableElement?.host
? getViewportElement(this.topContenteditableElement.host)
: null}"
data-parent-flavour="affine:database"
class="${titleRichTextStyle}"
></rich-text>`;
}
renderIcon() {
if (!this.showIcon) {
return;
}
if (this.docId$.value) {
return html` <div class="${headerAreaIconStyle}">
${LinkedPageIcon({})}
</div>`;
}
const iconColumn = this.view.mainProperties$.value.iconColumn;
if (!iconColumn) return;
const icon = this.view.cellValueGet(this.cell.rowId, iconColumn) as string;
if (!icon) return;
return html` <div class="${headerAreaIconStyle}">${icon}</div>`;
}
private readonly richText = createRef<RichText>();
@property({ attribute: false })
accessor showIcon = false;
}
declare global {
interface HTMLElementTagNameMap {
'data-view-header-area-text': HeaderAreaTextCell;
}
}