// related component import './common/group-by/define.js'; import './common/header/views.js'; import './common/header/title.js'; import './common/header/tools/tools.js'; import './common/filter/filter-bar.js'; import './data-view.js'; import { PathFinder } from '@blockeditor/block-std'; import { Slot } from '@blockeditor/global/utils'; import { BlockElement, RangeManager } from '@blockeditor/lit'; import { Slice } from '@blockeditor/store'; import { css, nothing, unsafeCSS } from 'lit'; import { customElement } from 'lit/decorators.js'; import { createRef, ref } from 'lit/directives/ref.js'; import { when } from 'lit/directives/when.js'; import { html } from 'lit/static-html.js'; import { DragIndicator } from '../_common/components/index.js'; import { popMenu } from '../_common/components/menu/index.js'; import { defineUniComponent } from '../_common/components/uni-component/uni-component.js'; import { CopyIcon, DeleteIcon, MoreHorizontalIcon, } from '../_common/icons/index.js'; import type { DataViewSelection } from '../_common/utils/index.js'; import { Rect } from '../_common/utils/index.js'; import type { NoteBlockComponent } from '../note-block/note-block.js'; import { EdgelessPageBlockComponent } from '../page-block/edgeless/edgeless-page-block.js'; import { AffineDragHandleWidget } from '../page-block/widgets/drag-handle/drag-handle.js'; import { captureEventTarget, getDropResult, } from '../page-block/widgets/drag-handle/utils.js'; import { dataViewCommonStyle } from './common/css-variable.js'; import type { DataViewProps, DataViewTypes } from './common/data-view.js'; import { type DataViewExpose } from './common/data-view.js'; import type { DataViewManager } from './common/data-view-manager.js'; import type { DataSource } from './common/datasource/base.js'; import { DatabaseBlockDatasource } from './common/datasource/database-block-datasource.js'; import { renderFilterBar } from './common/filter/filter-bar.js'; import { renderTools } from './common/header/tools/tools.js'; import { DatabaseSelection } from './common/selection.js'; import type { SingleViewSource, ViewSource } from './common/view-source.js'; import type { DataViewNative, DataViewNativeConfig } from './data-view.js'; import type { DatabaseBlockModel } from './database-model.js'; import { DatabaseBlockSchema } from './database-model.js'; import type { DatabaseService } from './database-service.js'; import type { InsertToPosition } from './types.js'; @customElement('affine-database') export class DatabaseBlockComponent extends BlockElement< DatabaseBlockModel, DatabaseService > { static override styles = css` ${unsafeCSS(dataViewCommonStyle('affine-database'))} affine-database { display: block; border-radius: 8px; background-color: var(--affine-background-primary-color); padding: 8px; margin: 8px -8px -8px; } .database-block-selected { background-color: var(--affine-hover-color); border-radius: 4px; } .database-ops { margin-top: 4px; padding: 2px; border-radius: 4px; display: flex; cursor: pointer; } .database-ops svg { width: 16px; height: 16px; color: var(--affine-icon-color); } .database-ops:hover { background-color: var(--affine-hover-color); } `; indicator = new DragIndicator(); onDrag = (evt: MouseEvent, id: string): (() => void) => { const result = getDropResult(evt); if (result && result.rect) { document.body.append(this.indicator); this.indicator.rect = Rect.fromLWTH( result.rect.left, result.rect.width, result.rect.top, result.rect.height ); return () => { this.indicator.remove(); const model = this.page.getBlockById(id); const target = this.page.getBlockById(result.dropBlockId); let parent = this.page.getParent(result.dropBlockId); const shouldInsertIn = result.dropType === 'in'; if (shouldInsertIn) { parent = target; } if (model && target && parent) { if (shouldInsertIn) { this.page.moveBlocks([model], parent); } else { this.page.moveBlocks( [model], parent, target, result.dropType === 'before' ); } } }; } this.indicator.remove(); return () => {}; }; private _clickDatabaseOps = (e: MouseEvent) => { popMenu(e.currentTarget as HTMLElement, { options: { input: { initValue: this.model.title.toString(), placeholder: 'Untitled', onComplete: text => { this.model.title.replace(0, this.model.title.length, text); }, }, items: [ { type: 'action', icon: CopyIcon, name: 'Copy', select: () => { const slice = Slice.fromModels(this.page, [this.model]); this.std.clipboard.copySlice(slice).catch(console.error); }, }, // { // type: 'action', // icon: DuplicateIcon, // name: 'Duplicate', // select: () => { // }, // }, { type: 'group', name: '', children: () => [ { type: 'action', icon: DeleteIcon, class: 'delete-item', name: 'Delete Database', select: () => { this.model.children.slice().forEach(block => { this.page.deleteBlock(block); }); this.page.deleteBlock(this.model); }, }, ], }, ], }, }); }; private renderDatabaseOps() { if (this.page.readonly) { return nothing; } return html`
${MoreHorizontalIcon}
`; } override get topContenteditableElement() { if (this.rootBlockElement instanceof EdgelessPageBlockComponent) { const note = this.closest('affine-note'); return note; } return this.rootBlockElement; } override connectedCallback() { super.connectedCallback(); this.setAttribute(RangeManager.rangeSyncExcludeAttr, 'true'); this._disposables.add( this.selection.slots.changed.on(selections => { const databaseSelection = selections.find( (selection): selection is DatabaseSelection => { if (!PathFinder.equals(selection.path, this.path)) { return false; } return selection instanceof DatabaseSelection; } ); this.selectionUpdated.emit(databaseSelection?.viewSelection); }) ); this._disposables.add( this.model.propsUpdated.on(() => { this.viewSource.updateSlot.emit(); }) ); let canDrop = false; this.disposables.add( AffineDragHandleWidget.registerOption({ flavour: DatabaseBlockSchema.model.flavour, onDragMove: state => { const target = captureEventTarget(state.raw.target); const view = this.view; if (view && target instanceof HTMLElement && this.contains(target)) { canDrop = view.showIndicator?.(state.raw) ?? false; return false; } if (canDrop) { view?.hideIndicator?.(); canDrop = false; } return false; }, onDragEnd: ({ state, draggingElements }) => { const target = state.raw.target; const view = this.view; if ( canDrop && view && view.moveTo && target instanceof HTMLElement && this.parentElement?.contains(target) ) { const blocks = draggingElements.map(v => v.model); this.model.page.moveBlocks(blocks, this.model); blocks.forEach(model => { view.moveTo?.(model.id, state.raw); }); view.hideIndicator?.(); return false; } if (canDrop) { view?.hideIndicator?.(); canDrop = false; } return false; }, }) ); } private _view = createRef(); get view() { return this._view.value?.expose; } private _dataSource?: DataSource; public get dataSource(): DataSource { if (!this._dataSource) { this._dataSource = new DatabaseBlockDatasource(this.host, { type: 'database-block', pageId: this.host.page.id, blockId: this.model.id, }); } return this._dataSource; } public focusFirstCell = () => { this._view.value?.focusFirstCell(); }; private renderViews = () => { return html` `; }; private renderTitle = (dataViewMethod: DataViewExpose) => { const addRow = () => dataViewMethod.addRow?.('start'); return html` `; }; private renderReference = () => { return html`
`; }; headerComponent = defineUniComponent( ({ view, viewMethods, }: { view: DataViewManager; viewMethods: DataViewExpose; }) => { return html`
${this.renderTitle(viewMethods)} ${this.renderDatabaseOps()} ${this.renderReference()}
${this.renderViews()} ${renderTools(view, viewMethods)}
${renderFilterBar(view)}
`; } ); private _viewSource?: ViewSource; public get viewSource(): ViewSource { if (!this._viewSource) { this._viewSource = new DatabaseBlockViewSource(this.model); } return this._viewSource; } setSelection = (selection: DataViewSelection | undefined) => { this.selection.setGroup( 'note', selection ? [new DatabaseSelection({ path: this.path, viewSelection: selection })] : [] ); }; selectionUpdated = new Slot(); get getFlag() { return this.host.page.awarenessStore.getFlag.bind( this.host.page.awarenessStore ); } _bindHotkey: DataViewProps['bindHotkey'] = hotkeys => { return { dispose: this.host.event.bindHotkey(hotkeys, { path: this.topContenteditableElement?.path ?? this.path, }), }; }; _handleEvent: DataViewProps['handleEvent'] = (name, handler) => { return { dispose: this.host.event.add(name, handler, { path: this.path, }), }; }; override renderBlock() { const config: DataViewNativeConfig = { bindHotkey: this._bindHotkey, handleEvent: this._handleEvent, getFlag: this.getFlag, selectionUpdated: this.selectionUpdated, setSelection: this.setSelection, dataSource: this.dataSource, viewSource: this.viewSource, headerComponent: this.headerComponent, onDrag: this.onDrag, std: this.std, }; return html`
${when( this.selected?.is('block'), () => html` ` )}
`; } } declare global { interface HTMLElementTagNameMap { 'affine-database': DatabaseBlockComponent; } } class DatabaseBlockViewSource implements ViewSource { constructor(private model: DatabaseBlockModel) {} get currentViewId(): string { return this.currentId ?? this.model.views[0].id; } private viewMap = new Map(); private currentId?: string; public selectView(id: string): void { this.currentId = id; this.updateSlot.emit(); } public updateSlot = new Slot(); public get views(): SingleViewSource[] { return this.model.views.map(v => this.viewGet(v.id)); } public get currentView(): SingleViewSource { return this.viewGet(this.currentViewId); } public get readonly(): boolean { return this.model.page.readonly; } public viewAdd(type: DataViewTypes): string { this.model.page.captureSync(); const view = this.model.addView(type); this.model.applyViewsUpdate(); return view.id; } public viewGet(id: string): SingleViewSource { let result = this.viewMap.get(id); if (!result) { const getView = () => { return this.model.views.find(v => v.id === id); }; const view = getView(); if (!view) { throw new Error('view not found'); } // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; const slot = new Slot(); this.updateSlot.pipe(slot); result = { duplicate(): void { self.duplicate(id); }, get view() { const view = getView(); if (!view) { throw new Error('view not found'); } return view; }, updateView: updater => { this.model.page.captureSync(); this.model.updateView(id, updater); this.model.applyViewsUpdate(); }, delete: () => { this.model.page.captureSync(); this.model.deleteView(id); this.currentId = undefined; this.model.applyViewsUpdate(); }, get readonly() { return self.model.page.readonly; }, updateSlot: slot, isDeleted() { return !self.model.views.find(v => v.id === id); }, }; this.viewMap.set(id, result); } return result; } public duplicate(id: string): void { const newId = this.model.duplicateView(id); this.selectView(newId); } public moveTo(id: string, position: InsertToPosition): void { this.model.moveViewTo(id, position); } }