mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-19 23:37:15 +08:00
refactor(editor): extract database block (#9435)
Part of: [BS-2269](https://linear.app/affine-design/issue/BS-2269/%E8%BF%81%E7%A7%BB-database-block-%E5%88%B0-affine-%E6%96%87%E4%BB%B6%E5%A4%B9%E4%B8%8B%E5%B9%B6%E5%BC%80%E5%90%AF-nouncheckedindexedaccess)
This commit is contained in:
176
blocksuite/affine/block-database/src/properties/converts.ts
Normal file
176
blocksuite/affine/block-database/src/properties/converts.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { clamp } from '@blocksuite/affine-shared/utils';
|
||||
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 { nanoid, Text } from '@blocksuite/store';
|
||||
|
||||
import { richTextColumnModelConfig } from './rich-text/define.js';
|
||||
|
||||
export const databasePropertyConverts = [
|
||||
...presetPropertyConverts,
|
||||
createPropertyConvert(
|
||||
richTextColumnModelConfig,
|
||||
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(
|
||||
richTextColumnModelConfig,
|
||||
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(
|
||||
richTextColumnModelConfig,
|
||||
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(
|
||||
richTextColumnModelConfig,
|
||||
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(
|
||||
richTextColumnModelConfig,
|
||||
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,
|
||||
richTextColumnModelConfig,
|
||||
(_property, cells) => {
|
||||
return {
|
||||
property: {},
|
||||
cells: cells.map(v => new Text(v ? 'Yes' : 'No').yText),
|
||||
};
|
||||
}
|
||||
),
|
||||
createPropertyConvert(
|
||||
propertyModelPresets.multiSelectPropertyModelConfig,
|
||||
richTextColumnModelConfig,
|
||||
(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,
|
||||
richTextColumnModelConfig,
|
||||
(_property, cells) => ({
|
||||
property: {},
|
||||
cells: cells.map(v => new Text(v?.toString()).yText),
|
||||
})
|
||||
),
|
||||
createPropertyConvert(
|
||||
propertyModelPresets.progressPropertyModelConfig,
|
||||
richTextColumnModelConfig,
|
||||
(_property, cells) => ({
|
||||
property: {},
|
||||
cells: cells.map(v => new Text(v?.toString()).yText),
|
||||
})
|
||||
),
|
||||
createPropertyConvert(
|
||||
propertyModelPresets.selectPropertyModelConfig,
|
||||
richTextColumnModelConfig,
|
||||
(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),
|
||||
};
|
||||
}
|
||||
),
|
||||
];
|
||||
38
blocksuite/affine/block-database/src/properties/index.ts
Normal file
38
blocksuite/affine/block-database/src/properties/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { PropertyMetaConfig } from '@blocksuite/data-view';
|
||||
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 databaseBlockColumns = {
|
||||
checkboxColumnConfig: checkboxPropertyConfig,
|
||||
dateColumnConfig: datePropertyConfig,
|
||||
multiSelectColumnConfig: multiSelectPropertyConfig,
|
||||
numberColumnConfig: numberPropertyConfig,
|
||||
progressColumnConfig: progressPropertyConfig,
|
||||
selectColumnConfig: selectPropertyConfig,
|
||||
linkColumnConfig,
|
||||
richTextColumnConfig,
|
||||
};
|
||||
export const databaseBlockPropertyList = Object.values(databaseBlockColumns);
|
||||
export const databaseBlockHiddenColumns = [
|
||||
propertyPresets.imagePropertyConfig,
|
||||
titleColumnConfig,
|
||||
];
|
||||
const databaseBlockAllColumns = [
|
||||
...databaseBlockPropertyList,
|
||||
...databaseBlockHiddenColumns,
|
||||
];
|
||||
export const databaseBlockAllPropertyMap = Object.fromEntries(
|
||||
databaseBlockAllColumns.map(v => [v.type, v as PropertyMetaConfig])
|
||||
);
|
||||
@@ -0,0 +1,256 @@
|
||||
import { RefNodeSlotsProvider } from '@blocksuite/affine-components/rich-text';
|
||||
import { ParseDocUrlProvider } from '@blocksuite/affine-shared/services';
|
||||
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import {
|
||||
isValidUrl,
|
||||
normalizeUrl,
|
||||
stopPropagation,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
BaseCellRenderer,
|
||||
createFromBaseCellRenderer,
|
||||
createIcon,
|
||||
} from '@blocksuite/data-view';
|
||||
import { EditIcon } from '@blocksuite/icons/lit';
|
||||
import { baseTheme } from '@toeverything/theme';
|
||||
import { css, nothing, unsafeCSS } from 'lit';
|
||||
import { query, state } from 'lit/decorators.js';
|
||||
import { html } from 'lit/static-html.js';
|
||||
|
||||
import { HostContextKey } from '../../context/host-context.js';
|
||||
import { linkColumnModelConfig } from './define.js';
|
||||
|
||||
export class LinkCell extends BaseCellRenderer<string> {
|
||||
static override styles = css`
|
||||
affine-database-link-cell {
|
||||
width: 100%;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
affine-database-link-cell:hover .affine-database-link-icon {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.affine-database-link {
|
||||
display: flex;
|
||||
position: relative;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
outline: none;
|
||||
overflow: hidden;
|
||||
font-size: var(--data-view-cell-text-size);
|
||||
line-height: var(--data-view-cell-text-line-height);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
affine-database-link-node {
|
||||
flex: 1;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.affine-database-link-icon {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
visibility: hidden;
|
||||
cursor: pointer;
|
||||
background: ${unsafeCSSVarV2('button/iconButtonSolid')};
|
||||
color: ${unsafeCSSVarV2('icon/primary')};
|
||||
box-shadow: var(--affine-button-shadow);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.affine-database-link-icon:hover {
|
||||
background: var(--affine-hover-color);
|
||||
}
|
||||
|
||||
.data-view-link-column-linked-doc {
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--affine-divider-color);
|
||||
transition: text-decoration-color 0.2s ease-out;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.data-view-link-column-linked-doc:hover {
|
||||
text-decoration-color: var(--affine-icon-color);
|
||||
}
|
||||
`;
|
||||
|
||||
private readonly _onClick = (event: Event) => {
|
||||
event.stopPropagation();
|
||||
const value = this.value ?? '';
|
||||
|
||||
if (!value || !isValidUrl(value)) {
|
||||
this.selectCurrentCell(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isValidUrl(value)) {
|
||||
const target = event.target as HTMLElement;
|
||||
const link = target.querySelector<HTMLAnchorElement>('.link-node');
|
||||
if (link) {
|
||||
event.preventDefault();
|
||||
link.click();
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
private readonly _onEdit = (e: Event) => {
|
||||
e.stopPropagation();
|
||||
this.selectCurrentCell(true);
|
||||
};
|
||||
|
||||
private preValue?: string;
|
||||
|
||||
openDoc = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!this.docId) {
|
||||
return;
|
||||
}
|
||||
const std = this.std;
|
||||
if (!std) {
|
||||
return;
|
||||
}
|
||||
|
||||
std
|
||||
.getOptional(RefNodeSlotsProvider)
|
||||
?.docLinkClicked.emit({ pageId: this.docId });
|
||||
};
|
||||
|
||||
get std() {
|
||||
const host = this.view.contextGet(HostContextKey);
|
||||
return host?.std;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const linkText = this.value ?? '';
|
||||
const docName =
|
||||
this.docId && this.std?.collection.getDoc(this.docId)?.meta?.title;
|
||||
return html`
|
||||
<div class="affine-database-link" @click="${this._onClick}">
|
||||
${docName
|
||||
? html`<span
|
||||
class="data-view-link-column-linked-doc"
|
||||
@click="${this.openDoc}"
|
||||
>${docName}</span
|
||||
>`
|
||||
: html` <affine-database-link-node
|
||||
.link="${linkText}"
|
||||
></affine-database-link-node>`}
|
||||
</div>
|
||||
${docName || linkText
|
||||
? html` <div class="affine-database-link-icon" @click="${this._onEdit}">
|
||||
${EditIcon()}
|
||||
</div>`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
override updated() {
|
||||
if (this.value !== this.preValue) {
|
||||
const std = this.std;
|
||||
this.preValue = this.value;
|
||||
if (!this.value || !isValidUrl(this.value)) {
|
||||
this.docId = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
this.docId =
|
||||
std?.getOptional(ParseDocUrlProvider)?.parseDocUrl(this.value)?.docId ??
|
||||
undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@state()
|
||||
accessor docId: string | undefined = undefined;
|
||||
}
|
||||
|
||||
export class LinkCellEditing extends BaseCellRenderer<string> {
|
||||
static override styles = css`
|
||||
affine-database-link-cell-editing {
|
||||
width: 100%;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.affine-database-link-editing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
|
||||
color: var(--affine-text-primary-color);
|
||||
font-weight: 400;
|
||||
background-color: transparent;
|
||||
font-size: var(--data-view-cell-text-size);
|
||||
line-height: var(--data-view-cell-text-line-height);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.affine-database-link-editing:focus {
|
||||
outline: none;
|
||||
}
|
||||
`;
|
||||
|
||||
private readonly _focusEnd = () => {
|
||||
const end = this._container.value.length;
|
||||
this._container.focus();
|
||||
this._container.setSelectionRange(end, end);
|
||||
};
|
||||
|
||||
private readonly _onKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.isComposing) {
|
||||
this._setValue();
|
||||
setTimeout(() => {
|
||||
this.selectCurrentCell(false);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private readonly _setValue = (value: string = this._container.value) => {
|
||||
let url = value;
|
||||
if (isValidUrl(value)) {
|
||||
url = normalizeUrl(value);
|
||||
}
|
||||
|
||||
this.onChange(url);
|
||||
this._container.value = url;
|
||||
};
|
||||
|
||||
override firstUpdated() {
|
||||
this._focusEnd();
|
||||
}
|
||||
|
||||
override onExitEditMode() {
|
||||
this._setValue();
|
||||
}
|
||||
|
||||
override render() {
|
||||
const linkText = this.value ?? '';
|
||||
|
||||
return html`<input
|
||||
class="affine-database-link-editing link"
|
||||
.value="${linkText}"
|
||||
@keydown="${this._onKeydown}"
|
||||
@pointerdown="${stopPropagation}"
|
||||
/>`;
|
||||
}
|
||||
|
||||
@query('.affine-database-link-editing')
|
||||
private accessor _container!: HTMLInputElement;
|
||||
}
|
||||
|
||||
export const linkColumnConfig = linkColumnModelConfig.createPropertyMeta({
|
||||
icon: createIcon('LinkIcon'),
|
||||
cellRenderer: {
|
||||
view: createFromBaseCellRenderer(LinkCell),
|
||||
edit: createFromBaseCellRenderer(LinkCellEditing),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { isValidUrl } from '@blocksuite/affine-shared/utils';
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { css, html } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
export class LinkNode extends ShadowlessElement {
|
||||
static override styles = css`
|
||||
.link-node {
|
||||
word-break: break-all;
|
||||
color: var(--affine-link-color);
|
||||
fill: var(--affine-link-color);
|
||||
cursor: pointer;
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
text-decoration: none;
|
||||
}
|
||||
`;
|
||||
|
||||
protected override render() {
|
||||
if (!isValidUrl(this.link)) {
|
||||
return html`<span class="normal-text">${this.link}</span>`;
|
||||
}
|
||||
|
||||
return html`<a
|
||||
class="link-node"
|
||||
href=${this.link}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
><span class="link-node-text">${this.link}</span></a
|
||||
>`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor link!: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-database-link-node': LinkNode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { propertyType, t } from '@blocksuite/data-view';
|
||||
|
||||
export const linkColumnType = propertyType('link');
|
||||
export const linkColumnModelConfig = linkColumnType.modelConfig<string>({
|
||||
name: 'Link',
|
||||
type: () => t.string.instance(),
|
||||
defaultData: () => ({}),
|
||||
cellToString: ({ value }) => value?.toString() ?? '',
|
||||
cellFromString: ({ value }) => {
|
||||
return {
|
||||
value: value,
|
||||
};
|
||||
},
|
||||
cellToJson: ({ value }) => value ?? null,
|
||||
cellFromJson: ({ value }) => (typeof value !== 'string' ? undefined : value),
|
||||
|
||||
isEmpty: ({ value }) => value == null || value.length == 0,
|
||||
});
|
||||
@@ -0,0 +1,398 @@
|
||||
import {
|
||||
type AffineInlineEditor,
|
||||
DefaultInlineManagerExtension,
|
||||
type RichText,
|
||||
} from '@blocksuite/affine-components/rich-text';
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import { getViewportElement } from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
BaseCellRenderer,
|
||||
createFromBaseCellRenderer,
|
||||
createIcon,
|
||||
} from '@blocksuite/data-view';
|
||||
import { IS_MAC } from '@blocksuite/global/env';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import { Text } from '@blocksuite/store';
|
||||
import { css, nothing, type PropertyValues } from 'lit';
|
||||
import { query } from 'lit/decorators.js';
|
||||
import { keyed } from 'lit/directives/keyed.js';
|
||||
import { html } from 'lit/static-html.js';
|
||||
|
||||
import { HostContextKey } from '../../context/host-context.js';
|
||||
import type { DatabaseBlockComponent } from '../../database-block.js';
|
||||
import { richTextColumnModelConfig } from './define.js';
|
||||
|
||||
function toggleStyle(
|
||||
inlineEditor: AffineInlineEditor,
|
||||
attrs: AffineTextAttributes
|
||||
): void {
|
||||
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> {
|
||||
static override styles = css`
|
||||
affine-database-rich-text-cell {
|
||||
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;
|
||||
}
|
||||
`;
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.changeUserSelectAccordToReadOnly();
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (!this.service) return nothing;
|
||||
if (!this.value || !(this.value instanceof Text)) {
|
||||
return html`<div class="affine-database-rich-text"></div>`;
|
||||
}
|
||||
return keyed(
|
||||
this.value,
|
||||
html`<rich-text
|
||||
.yText=${this.value}
|
||||
.attributesSchema=${this.attributesSchema}
|
||||
.attributeRenderer=${this.attributeRenderer}
|
||||
.embedChecker=${this.inlineManager?.embedChecker}
|
||||
.markdownShortcutHandler=${this.inlineManager?.markdownShortcutHandler}
|
||||
.readonly=${true}
|
||||
class="affine-database-rich-text inline-editor"
|
||||
></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> {
|
||||
static override styles = css`
|
||||
affine-database-rich-text-cell-editing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 1px;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.affine-database-rich-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
`;
|
||||
|
||||
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;
|
||||
|
||||
switch (event.key) {
|
||||
// bold ctrl+b
|
||||
case 'B':
|
||||
case 'b':
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
event.preventDefault();
|
||||
toggleStyle(this.inlineEditor, { bold: true });
|
||||
}
|
||||
break;
|
||||
// italic ctrl+i
|
||||
case 'I':
|
||||
case 'i':
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
event.preventDefault();
|
||||
toggleStyle(this.inlineEditor, { italic: true });
|
||||
}
|
||||
break;
|
||||
// underline ctrl+u
|
||||
case 'U':
|
||||
case 'u':
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
event.preventDefault();
|
||||
toggleStyle(this.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.onChange(yText);
|
||||
};
|
||||
|
||||
private readonly _onSoftEnter = () => {
|
||||
if (this.value && this.inlineEditor) {
|
||||
const inlineRange = this.inlineEditor.getInlineRange();
|
||||
assertExists(inlineRange);
|
||||
|
||||
const text = new Text(this.inlineEditor.yText);
|
||||
text.replace(inlineRange.index, inlineRange.length, '\n');
|
||||
this.inlineEditor.setInlineRange({
|
||||
index: inlineRange.index + 1,
|
||||
length: 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
assertExists(inlineEditor);
|
||||
return inlineEditor;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
get inlineManager() {
|
||||
return this.view
|
||||
.contextGet(HostContextKey)
|
||||
?.std.get(DefaultInlineManagerExtension.identifier);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
get service() {
|
||||
return this.view
|
||||
.contextGet(HostContextKey)
|
||||
?.std.getService('affine:database');
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
get topContenteditableElement() {
|
||||
const databaseBlock =
|
||||
this.closest<DatabaseBlockComponent>('affine-database');
|
||||
return databaseBlock?.topContenteditableElement;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
if (!this.value || typeof this.value === 'string') {
|
||||
this._initYText(this.value);
|
||||
}
|
||||
|
||||
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() {
|
||||
this._richTextElement?.updateComplete
|
||||
.then(() => {
|
||||
this.disposables.add(
|
||||
this.inlineEditor.slots.keydown.on(this._handleKeyDown)
|
||||
);
|
||||
|
||||
this.inlineEditor.focusEnd();
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (!this.service) return nothing;
|
||||
return html`<rich-text
|
||||
.yText=${this.value}
|
||||
.inlineEventSource=${this.topContenteditableElement}
|
||||
.attributesSchema=${this.attributesSchema}
|
||||
.attributeRenderer=${this.attributeRenderer}
|
||||
.embedChecker=${this.inlineManager?.embedChecker}
|
||||
.markdownShortcutHandler=${this.inlineManager?.markdownShortcutHandler}
|
||||
.verticalScrollContainerGetter=${() =>
|
||||
this.topContenteditableElement?.host
|
||||
? getViewportElement(this.topContenteditableElement.host)
|
||||
: null}
|
||||
class="affine-database-rich-text inline-editor"
|
||||
></rich-text>`;
|
||||
}
|
||||
|
||||
@query('rich-text')
|
||||
private accessor _richTextElement: RichText | null = null;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-database-rich-text-cell-editing': RichTextCellEditing;
|
||||
}
|
||||
}
|
||||
|
||||
export const richTextColumnConfig =
|
||||
richTextColumnModelConfig.createPropertyMeta({
|
||||
icon: createIcon('TextIcon'),
|
||||
|
||||
cellRenderer: {
|
||||
view: createFromBaseCellRenderer(RichTextCell),
|
||||
edit: createFromBaseCellRenderer(RichTextCellEditing),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { propertyType, t } from '@blocksuite/data-view';
|
||||
import { Text } from '@blocksuite/store';
|
||||
|
||||
import { type RichTextCellType, toYText } from '../utils.js';
|
||||
|
||||
export const richTextColumnType = propertyType('rich-text');
|
||||
|
||||
export const richTextColumnModelConfig =
|
||||
richTextColumnType.modelConfig<RichTextCellType>({
|
||||
name: 'Text',
|
||||
type: () => t.richText.instance(),
|
||||
defaultData: () => ({}),
|
||||
cellToString: ({ value }) => value?.toString() ?? '',
|
||||
cellFromString: ({ value }) => {
|
||||
return {
|
||||
value: new Text(value),
|
||||
};
|
||||
},
|
||||
cellToJson: ({ value }) => value?.toString() ?? null,
|
||||
cellFromJson: ({ value }) =>
|
||||
typeof value !== 'string' ? undefined : new Text(value),
|
||||
onUpdate: ({ value, callback }) => {
|
||||
const yText = toYText(value);
|
||||
yText.observe(callback);
|
||||
callback();
|
||||
return {
|
||||
dispose: () => {
|
||||
yText.unobserve(callback);
|
||||
},
|
||||
};
|
||||
},
|
||||
isEmpty: ({ value }) => value == null || value.length === 0,
|
||||
values: ({ value }) => (value?.toString() ? [value.toString()] : []),
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
type CellRenderProps,
|
||||
createFromBaseCellRenderer,
|
||||
createIcon,
|
||||
uniMap,
|
||||
} from '@blocksuite/data-view';
|
||||
import { TableSingleView } from '@blocksuite/data-view/view-presets';
|
||||
|
||||
import { titlePurePropertyConfig } from './define.js';
|
||||
import { HeaderAreaTextCell, HeaderAreaTextCellEditing } from './text.js';
|
||||
|
||||
export const titleColumnConfig = titlePurePropertyConfig.createPropertyMeta({
|
||||
icon: createIcon('TitleIcon'),
|
||||
cellRenderer: {
|
||||
view: uniMap(
|
||||
createFromBaseCellRenderer(HeaderAreaTextCell),
|
||||
(props: CellRenderProps) => ({
|
||||
...props,
|
||||
showIcon: props.cell.view instanceof TableSingleView,
|
||||
})
|
||||
),
|
||||
edit: uniMap(
|
||||
createFromBaseCellRenderer(HeaderAreaTextCellEditing),
|
||||
(props: CellRenderProps) => ({
|
||||
...props,
|
||||
showIcon: props.cell.view instanceof TableSingleView,
|
||||
})
|
||||
),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { propertyType, t } from '@blocksuite/data-view';
|
||||
import { Text } from '@blocksuite/store';
|
||||
|
||||
import { HostContextKey } from '../../context/host-context.js';
|
||||
import { isLinkedDoc } from '../../utils/title-doc.js';
|
||||
|
||||
export const titleColumnType = propertyType('title');
|
||||
|
||||
export const titlePurePropertyConfig = titleColumnType.modelConfig<Text>({
|
||||
name: 'Title',
|
||||
type: () => t.richText.instance(),
|
||||
defaultData: () => ({}),
|
||||
cellToString: ({ value }) => value?.toString() ?? '',
|
||||
cellFromString: ({ value }) => {
|
||||
return {
|
||||
value: value,
|
||||
};
|
||||
},
|
||||
cellToJson: ({ value, dataSource }) => {
|
||||
const host = dataSource.contextGet(HostContextKey);
|
||||
if (host) {
|
||||
const collection = host.std.collection;
|
||||
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() ?? null;
|
||||
},
|
||||
cellFromJson: ({ value }) =>
|
||||
typeof value !== 'string' ? undefined : new Text(value),
|
||||
onUpdate: ({ value, callback }) => {
|
||||
value.yText.observe(callback);
|
||||
callback();
|
||||
return {
|
||||
dispose: () => {
|
||||
value.yText.unobserve(callback);
|
||||
},
|
||||
};
|
||||
},
|
||||
valueUpdate: ({ value, newValue }) => {
|
||||
const v = newValue as unknown;
|
||||
if (typeof v === 'string') {
|
||||
value.replace(0, value.length, v);
|
||||
return value;
|
||||
}
|
||||
if (v == null) {
|
||||
value.replace(0, value.length, '');
|
||||
return value;
|
||||
}
|
||||
return newValue;
|
||||
},
|
||||
isEmpty: ({ value }) => value == null || value.length === 0,
|
||||
values: ({ value }) => (value?.toString() ? [value.toString()] : []),
|
||||
});
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
428
blocksuite/affine/block-database/src/properties/title/text.ts
Normal file
428
blocksuite/affine/block-database/src/properties/title/text.ts
Normal file
@@ -0,0 +1,428 @@
|
||||
import {
|
||||
DefaultInlineManagerExtension,
|
||||
type RichText,
|
||||
} from '@blocksuite/affine-components/rich-text';
|
||||
import type { RootBlockModel } from '@blocksuite/affine-model';
|
||||
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 { assertExists } from '@blocksuite/global/utils';
|
||||
import { LinkedPageIcon } from '@blocksuite/icons/lit';
|
||||
import type { DeltaInsert } from '@blocksuite/inline';
|
||||
import type { BlockSnapshot, Text } from '@blocksuite/store';
|
||||
import { computed, effect, signal } from '@preact/signals-core';
|
||||
import { css, type TemplateResult } from 'lit';
|
||||
import { property, query } from 'lit/decorators.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';
|
||||
|
||||
const styles = css`
|
||||
data-view-header-area-text {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
data-view-header-area-text rich-text {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
data-view-header-area-text-editing {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.data-view-header-area-rich-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
outline: none;
|
||||
word-break: break-all;
|
||||
font-size: var(--data-view-cell-text-size);
|
||||
line-height: var(--data-view-cell-text-line-height);
|
||||
}
|
||||
|
||||
.data-view-header-area-rich-text v-line {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.data-view-header-area-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);
|
||||
}
|
||||
`;
|
||||
|
||||
abstract class BaseTextCell extends BaseCellRenderer<Text> {
|
||||
static override styles = styles;
|
||||
|
||||
activity = true;
|
||||
|
||||
docId$ = signal<string>();
|
||||
|
||||
isLinkedDoc$ = computed(() => false);
|
||||
|
||||
linkedDocTitle$ = computed(() => {
|
||||
if (!this.docId$.value) {
|
||||
return this.value;
|
||||
}
|
||||
const doc = this.host?.std.collection.getDoc(this.docId$.value);
|
||||
const root = doc?.root as RootBlockModel;
|
||||
return root.title;
|
||||
});
|
||||
|
||||
get attributeRenderer() {
|
||||
return this.inlineManager?.getRenderer();
|
||||
}
|
||||
|
||||
get attributesSchema() {
|
||||
return this.inlineManager?.getSchema();
|
||||
}
|
||||
|
||||
get host() {
|
||||
return this.view.contextGet(HostContextKey);
|
||||
}
|
||||
|
||||
get inlineEditor() {
|
||||
return this.richText.inlineEditor;
|
||||
}
|
||||
|
||||
get inlineManager() {
|
||||
return this.host?.std.get(DefaultInlineManagerExtension.identifier);
|
||||
}
|
||||
|
||||
get service() {
|
||||
return this.host?.std.getService('affine:database');
|
||||
}
|
||||
|
||||
get topContenteditableElement() {
|
||||
const databaseBlock =
|
||||
this.closest<DatabaseBlockComponent>('affine-database');
|
||||
return databaseBlock?.topContenteditableElement;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override render(): unknown {
|
||||
return html`${this.renderIcon()}${this.renderBlockText()}`;
|
||||
}
|
||||
|
||||
abstract renderBlockText(): TemplateResult;
|
||||
|
||||
renderIcon() {
|
||||
if (this.docId$.value) {
|
||||
return html` <div class="data-view-header-area-icon">
|
||||
${LinkedPageIcon()}
|
||||
</div>`;
|
||||
}
|
||||
if (!this.showIcon) {
|
||||
return;
|
||||
}
|
||||
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="data-view-header-area-icon">${icon}</div>`;
|
||||
}
|
||||
|
||||
abstract renderLinkedDoc(): TemplateResult;
|
||||
|
||||
@query('rich-text')
|
||||
accessor richText!: RichText;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor showIcon = false;
|
||||
}
|
||||
|
||||
export class HeaderAreaTextCell extends BaseTextCell {
|
||||
override renderBlockText() {
|
||||
return html` <rich-text
|
||||
.yText="${this.value}"
|
||||
.attributesSchema="${this.attributesSchema}"
|
||||
.attributeRenderer="${this.attributeRenderer}"
|
||||
.embedChecker="${this.inlineManager?.embedChecker}"
|
||||
.markdownShortcutHandler="${this.inlineManager?.markdownShortcutHandler}"
|
||||
.readonly="${true}"
|
||||
class="data-view-header-area-rich-text"
|
||||
></rich-text>`;
|
||||
}
|
||||
|
||||
override renderLinkedDoc(): TemplateResult {
|
||||
return html` <rich-text
|
||||
.yText="${this.linkedDocTitle$.value}"
|
||||
.readonly="${true}"
|
||||
class="data-view-header-area-rich-text"
|
||||
></rich-text>`;
|
||||
}
|
||||
}
|
||||
|
||||
export class HeaderAreaTextCellEditing extends BaseTextCell {
|
||||
private readonly _onCopy = (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
|
||||
);
|
||||
|
||||
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;
|
||||
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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
override activity = false;
|
||||
|
||||
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,
|
||||
});
|
||||
};
|
||||
|
||||
private get std() {
|
||||
return this.host?.std;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
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.add(() => {
|
||||
this.removeEventListener('keydown', selectAll);
|
||||
});
|
||||
}
|
||||
|
||||
override firstUpdated(props: Map<string, unknown>) {
|
||||
super.firstUpdated(props);
|
||||
if (!this.isLinkedDoc$.value) {
|
||||
this.disposables.addFromEvent(this.richText, 'copy', this._onCopy);
|
||||
this.disposables.addFromEvent(this.richText, 'cut', this._onCut);
|
||||
this.disposables.addFromEvent(this.richText, 'paste', this._onPaste);
|
||||
}
|
||||
this.richText.updateComplete
|
||||
.then(() => {
|
||||
this.inlineEditor?.focusEnd();
|
||||
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
const inlineRange = this.inlineEditor?.inlineRange$.value;
|
||||
if (inlineRange) {
|
||||
if (!this.isEditing) {
|
||||
this.selectCurrentCell(true);
|
||||
}
|
||||
} else {
|
||||
if (this.isEditing) {
|
||||
this.selectCurrentCell(false);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
override renderBlockText() {
|
||||
return html` <rich-text
|
||||
.yText="${this.value}"
|
||||
.inlineEventSource="${this.topContenteditableElement}"
|
||||
.attributesSchema="${this.attributesSchema}"
|
||||
.attributeRenderer="${this.attributeRenderer}"
|
||||
.embedChecker="${this.inlineManager?.embedChecker}"
|
||||
.markdownShortcutHandler="${this.inlineManager?.markdownShortcutHandler}"
|
||||
.readonly="${this.readonly}"
|
||||
.enableClipboard="${false}"
|
||||
.verticalScrollContainerGetter="${() =>
|
||||
this.topContenteditableElement?.host
|
||||
? getViewportElement(this.topContenteditableElement.host)
|
||||
: null}"
|
||||
data-parent-flavour="affine:database"
|
||||
class="data-view-header-area-rich-text can-link-doc"
|
||||
></rich-text>`;
|
||||
}
|
||||
|
||||
override renderLinkedDoc(): TemplateResult {
|
||||
return html` <rich-text
|
||||
.yText="${this.linkedDocTitle$.value}"
|
||||
.inlineEventSource="${this.topContenteditableElement}"
|
||||
.readonly="${this.readonly}"
|
||||
.enableClipboard="${true}"
|
||||
.verticalScrollContainerGetter="${() =>
|
||||
this.topContenteditableElement?.host
|
||||
? getViewportElement(this.topContenteditableElement.host)
|
||||
: null}"
|
||||
class="data-view-header-area-rich-text"
|
||||
></rich-text>`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'data-view-header-area-text': HeaderAreaTextCell;
|
||||
'data-view-header-area-text-editing': HeaderAreaTextCellEditing;
|
||||
}
|
||||
}
|
||||
9
blocksuite/affine/block-database/src/properties/utils.ts
Normal file
9
blocksuite/affine/block-database/src/properties/utils.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Text } from '@blocksuite/store';
|
||||
|
||||
export type RichTextCellType = Text | Text['yText'];
|
||||
export const toYText = (text: RichTextCellType): Text['yText'] => {
|
||||
if (text instanceof Text) {
|
||||
return text.yText;
|
||||
}
|
||||
return text;
|
||||
};
|
||||
Reference in New Issue
Block a user