From ca3554d46ffe7ff2b5caef2194972947d68d4a16 Mon Sep 17 00:00:00 2001 From: "caojiafu@cvte.com" Date: Mon, 21 Oct 2024 18:19:50 +0800 Subject: [PATCH 01/13] feat(blocks): fork microsheet-block from database-block --- .../src/core/common/selection-schema.ts | 59 +++ packages/affine/model/src/blocks/index.ts | 1 + .../model/src/blocks/microsheet/index.ts | 2 + .../src/blocks/microsheet/microsheet-model.ts | 33 ++ .../model/src/blocks/microsheet/types.ts | 21 + .../model/src/blocks/note/note-model.ts | 1 + packages/blocks/src/_specs/common.ts | 3 + packages/blocks/src/_specs/group/common.ts | 2 + packages/blocks/src/effects.ts | 7 + packages/blocks/src/index.ts | 1 + .../src/microsheet-block/block-icons.ts | 47 ++ .../src/microsheet-block/components/layout.ts | 69 +++ .../components/title/index.ts | 176 +++++++ .../blocks/src/microsheet-block/config.ts | 61 +++ .../microsheet-block/context/host-context.ts | 8 + .../src/microsheet-block/data-source.ts | 490 ++++++++++++++++++ .../detail-panel/block-renderer.ts | 159 ++++++ .../detail-panel/note-renderer.ts | 129 +++++ packages/blocks/src/microsheet-block/index.ts | 15 + .../src/microsheet-block/microsheet-block.ts | 412 +++++++++++++++ .../microsheet-block/microsheet-service.ts | 60 +++ .../src/microsheet-block/microsheet-spec.ts | 18 + .../microsheet-block/properties/converts.ts | 177 +++++++ .../src/microsheet-block/properties/index.ts | 41 ++ .../properties/link/cell-renderer.ts | 254 +++++++++ .../properties/link/components/link-node.ts | 41 ++ .../properties/link/define.ts | 16 + .../properties/rich-text/cell-renderer.ts | 395 ++++++++++++++ .../properties/rich-text/define.ts | 32 ++ .../properties/title/cell-renderer.ts | 30 ++ .../properties/title/define.ts | 41 ++ .../microsheet-block/properties/title/icon.ts | 21 + .../microsheet-block/properties/title/text.ts | 319 ++++++++++++ .../src/microsheet-block/properties/utils.ts | 9 + packages/blocks/src/microsheet-block/utils.ts | 240 +++++++++ .../src/microsheet-block/views/index.ts | 13 + .../src/microsheet-block/widgets/index.ts | 1 + .../root-block/widgets/slash-menu/config.ts | 27 + .../root-block/widgets/slash-menu/utils.ts | 28 + packages/blocks/src/schemas.ts | 2 + 40 files changed, 3461 insertions(+) create mode 100644 packages/affine/model/src/blocks/microsheet/index.ts create mode 100644 packages/affine/model/src/blocks/microsheet/microsheet-model.ts create mode 100644 packages/affine/model/src/blocks/microsheet/types.ts create mode 100644 packages/blocks/src/microsheet-block/block-icons.ts create mode 100644 packages/blocks/src/microsheet-block/components/layout.ts create mode 100644 packages/blocks/src/microsheet-block/components/title/index.ts create mode 100644 packages/blocks/src/microsheet-block/config.ts create mode 100644 packages/blocks/src/microsheet-block/context/host-context.ts create mode 100644 packages/blocks/src/microsheet-block/data-source.ts create mode 100644 packages/blocks/src/microsheet-block/detail-panel/block-renderer.ts create mode 100644 packages/blocks/src/microsheet-block/detail-panel/note-renderer.ts create mode 100644 packages/blocks/src/microsheet-block/index.ts create mode 100644 packages/blocks/src/microsheet-block/microsheet-block.ts create mode 100644 packages/blocks/src/microsheet-block/microsheet-service.ts create mode 100644 packages/blocks/src/microsheet-block/microsheet-spec.ts create mode 100644 packages/blocks/src/microsheet-block/properties/converts.ts create mode 100644 packages/blocks/src/microsheet-block/properties/index.ts create mode 100644 packages/blocks/src/microsheet-block/properties/link/cell-renderer.ts create mode 100644 packages/blocks/src/microsheet-block/properties/link/components/link-node.ts create mode 100644 packages/blocks/src/microsheet-block/properties/link/define.ts create mode 100644 packages/blocks/src/microsheet-block/properties/rich-text/cell-renderer.ts create mode 100644 packages/blocks/src/microsheet-block/properties/rich-text/define.ts create mode 100644 packages/blocks/src/microsheet-block/properties/title/cell-renderer.ts create mode 100644 packages/blocks/src/microsheet-block/properties/title/define.ts create mode 100644 packages/blocks/src/microsheet-block/properties/title/icon.ts create mode 100644 packages/blocks/src/microsheet-block/properties/title/text.ts create mode 100644 packages/blocks/src/microsheet-block/properties/utils.ts create mode 100644 packages/blocks/src/microsheet-block/utils.ts create mode 100644 packages/blocks/src/microsheet-block/views/index.ts create mode 100644 packages/blocks/src/microsheet-block/widgets/index.ts diff --git a/packages/affine/data-view/src/core/common/selection-schema.ts b/packages/affine/data-view/src/core/common/selection-schema.ts index db6ee5bd567e..23cd19dcf3ac 100644 --- a/packages/affine/data-view/src/core/common/selection-schema.ts +++ b/packages/affine/data-view/src/core/common/selection-schema.ts @@ -122,6 +122,63 @@ export class DatabaseSelection extends BaseSelection { } } +export class MicrosheetSelection extends BaseSelection { + static override group = 'note'; + + static override type = 'microsheet'; + + readonly viewSelection: DataViewSelection; + + get viewId() { + return this.viewSelection.viewId; + } + + constructor({ + blockId, + viewSelection, + }: { + blockId: string; + viewSelection: DataViewSelection; + }) { + super({ + blockId, + }); + + this.viewSelection = viewSelection; + } + + static override fromJSON(json: Record): DatabaseSelection { + DatabaseSelectionSchema.parse(json); + return new DatabaseSelection({ + blockId: json.blockId as string, + viewSelection: json.viewSelection as DataViewSelection, + }); + } + + override equals(other: BaseSelection): boolean { + if (!(other instanceof DatabaseSelection)) { + return false; + } + return this.blockId === other.blockId; + } + + getSelection( + type: T + ): GetDataViewSelection | undefined { + return this.viewSelection.type === type + ? (this.viewSelection as GetDataViewSelection) + : undefined; + } + + override toJSON(): Record { + return { + type: 'microsheet', + blockId: this.blockId, + viewSelection: this.viewSelection, + }; + } +} + declare global { namespace BlockSuite { interface Selection { @@ -131,3 +188,5 @@ declare global { } export const DatabaseSelectionExtension = SelectionExtension(DatabaseSelection); +export const MicrosheetSelectionExtension = + SelectionExtension(MicrosheetSelection); diff --git a/packages/affine/model/src/blocks/index.ts b/packages/affine/model/src/blocks/index.ts index 67d6f1e4bbd2..922e44a2b1e6 100644 --- a/packages/affine/model/src/blocks/index.ts +++ b/packages/affine/model/src/blocks/index.ts @@ -9,6 +9,7 @@ export * from './frame/index.js'; export * from './image/index.js'; export * from './latex/index.js'; export * from './list/index.js'; +export * from './microsheet/index.js'; export * from './note/index.js'; export * from './paragraph/index.js'; export * from './root/index.js'; diff --git a/packages/affine/model/src/blocks/microsheet/index.ts b/packages/affine/model/src/blocks/microsheet/index.ts new file mode 100644 index 000000000000..d650b1350482 --- /dev/null +++ b/packages/affine/model/src/blocks/microsheet/index.ts @@ -0,0 +1,2 @@ +export * from './microsheet-model.js'; +export * from './types.js'; diff --git a/packages/affine/model/src/blocks/microsheet/microsheet-model.ts b/packages/affine/model/src/blocks/microsheet/microsheet-model.ts new file mode 100644 index 000000000000..4852cdd72b22 --- /dev/null +++ b/packages/affine/model/src/blocks/microsheet/microsheet-model.ts @@ -0,0 +1,33 @@ +import type { Text } from '@blocksuite/store'; + +import { BlockModel, defineBlockSchema } from '@blocksuite/store'; + +import type { Column, SerializedCells, ViewBasicDataType } from './types.js'; + +export type MicrosheetBlockProps = { + views: ViewBasicDataType[]; + title: Text; + cells: SerializedCells; + columns: Array; + // rowId -> pageId + notes?: Record; +}; + +export class MicrosheetBlockModel extends BlockModel {} + +export const MicrosheetBlockSchema = defineBlockSchema({ + flavour: 'affine:microsheet', + props: (internal): MicrosheetBlockProps => ({ + views: [], + title: internal.Text(), + cells: Object.create(null), + columns: [], + }), + metadata: { + role: 'hub', + version: 3, + parent: ['affine:note'], + children: [], + }, + toModel: () => new MicrosheetBlockModel(), +}); diff --git a/packages/affine/model/src/blocks/microsheet/types.ts b/packages/affine/model/src/blocks/microsheet/types.ts new file mode 100644 index 000000000000..8cbb6480a702 --- /dev/null +++ b/packages/affine/model/src/blocks/microsheet/types.ts @@ -0,0 +1,21 @@ +// export interface Column< +// Data extends Record = Record, +// > { +// id: string; +// type: string; +// name: string; +// data: Data; +// } + +// export type ColumnUpdater = (data: T) => Partial; +// export type Cell = { +// columnId: Column['id']; +// value: ValueType; +// }; + +// export type SerializedCells = Record>; +// export type ViewBasicDataType = { +// id: string; +// name: string; +// mode: string; +// }; diff --git a/packages/affine/model/src/blocks/note/note-model.ts b/packages/affine/model/src/blocks/note/note-model.ts index 203fb61e21f5..67e6547b2469 100644 --- a/packages/affine/model/src/blocks/note/note-model.ts +++ b/packages/affine/model/src/blocks/note/note-model.ts @@ -45,6 +45,7 @@ export const NoteBlockSchema = defineBlockSchema({ 'affine:code', 'affine:divider', 'affine:database', + 'affine:microsheet', 'affine:data-view', 'affine:image', 'affine:bookmark', diff --git a/packages/blocks/src/_specs/common.ts b/packages/blocks/src/_specs/common.ts index 4f80dabb9898..669ac04a490f 100644 --- a/packages/blocks/src/_specs/common.ts +++ b/packages/blocks/src/_specs/common.ts @@ -13,6 +13,7 @@ import { DataViewBlockSpec } from '../data-view-block/data-view-spec.js'; import { DatabaseBlockSpec } from '../database-block/database-spec.js'; import { DividerBlockSpec } from '../divider-block/divider-spec.js'; import { ImageBlockSpec } from '../image-block/image-spec.js'; +import { MicrosheetBlockSpec } from '../microsheet-block/microsheet-spec.js'; import { EdgelessNoteBlockSpec, NoteBlockSpec, @@ -24,6 +25,7 @@ export const CommonFirstPartyBlockSpecs: ExtensionType[] = [ ListBlockSpec, NoteBlockSpec, DatabaseBlockSpec, + MicrosheetBlockSpec, DataViewBlockSpec, DividerBlockSpec, CodeBlockSpec, @@ -40,6 +42,7 @@ export const EdgelessFirstPartyBlockSpecs: ExtensionType[] = [ ListBlockSpec, EdgelessNoteBlockSpec, DatabaseBlockSpec, + MicrosheetBlockSpec, DataViewBlockSpec, DividerBlockSpec, CodeBlockSpec, diff --git a/packages/blocks/src/_specs/group/common.ts b/packages/blocks/src/_specs/group/common.ts index 10df7ac8ba7f..39f638c18b9d 100644 --- a/packages/blocks/src/_specs/group/common.ts +++ b/packages/blocks/src/_specs/group/common.ts @@ -17,6 +17,7 @@ import { DataViewBlockSpec } from '../../data-view-block/data-view-spec.js'; import { DatabaseBlockSpec } from '../../database-block/database-spec.js'; import { DividerBlockSpec } from '../../divider-block/divider-spec.js'; import { ImageBlockSpec } from '../../image-block/image-spec.js'; +import { MicrosheetBlockSpec } from '../../microsheet-block/microsheet-spec.js'; import { EdgelessNoteBlockSpec, NoteBlockSpec, @@ -39,6 +40,7 @@ export { EmbedYoutubeBlockSpec, ImageBlockSpec, ListBlockSpec, + MicrosheetBlockSpec, NoteBlockSpec, ParagraphBlockSpec, }; diff --git a/packages/blocks/src/effects.ts b/packages/blocks/src/effects.ts index bf11a80f5676..a372ba3b9ffa 100644 --- a/packages/blocks/src/effects.ts +++ b/packages/blocks/src/effects.ts @@ -19,6 +19,11 @@ import { effects as inlineEffects } from '@blocksuite/inline/effects'; import type { insertBookmarkCommand } from './bookmark-block/commands/insert-bookmark.js'; import type { insertEdgelessTextCommand } from './edgeless-text-block/commands/insert-edgeless-text.js'; +import type { + MicrosheetBlockComponent, + MicrosheetBlockService, + type MicrosheetBlockService, +} from './microsheet-block/index.js'; import type { updateBlockType } from './note-block/commands/block-type.js'; import type { dedentBlock } from './note-block/commands/dedent-block.js'; import type { dedentBlockToRoot } from './note-block/commands/dedent-block-to-root.js'; @@ -410,6 +415,7 @@ export function effects() { ); customElements.define('affine-custom-modal', AffineCustomModal); customElements.define('affine-database', DatabaseBlockComponent); + customElements.define('affine-microsheet', MicrosheetBlockComponent); customElements.define('affine-surface-ref', SurfaceRefBlockComponent); customElements.define('pie-node-child', PieNodeChild); customElements.define('pie-node-content', PieNodeContent); @@ -721,6 +727,7 @@ declare global { 'affine:attachment': AttachmentBlockService; 'affine:bookmark': BookmarkBlockService; 'affine:database': DatabaseBlockService; + 'affine:microsheet': MicrosheetBlockService; 'affine:image': ImageBlockService; 'affine:surface-ref': SurfaceRefBlockService; } diff --git a/packages/blocks/src/index.ts b/packages/blocks/src/index.ts index 17f0f2ff4e32..9e89f1a3f534 100644 --- a/packages/blocks/src/index.ts +++ b/packages/blocks/src/index.ts @@ -29,6 +29,7 @@ export * from './edgeless-text-block/index.js'; export * from './frame-block/index.js'; export * from './image-block/index.js'; export * from './latex-block/index.js'; +export * from './microsheet-block/index.js'; export * from './note-block/index.js'; export { EdgelessTemplatePanel } from './root-block/edgeless/components/toolbar/template/template-panel.js'; export type { diff --git a/packages/blocks/src/microsheet-block/block-icons.ts b/packages/blocks/src/microsheet-block/block-icons.ts new file mode 100644 index 000000000000..8a6868003c53 --- /dev/null +++ b/packages/blocks/src/microsheet-block/block-icons.ts @@ -0,0 +1,47 @@ +import type { ParagraphType } from '@blocksuite/affine-model'; +import type { BlockModel } from '@blocksuite/store'; +import type { TemplateResult } from 'lit'; + +import { + BulletedListIcon, + CheckBoxCheckLinearIcon, + Heading1Icon, + Heading2Icon, + Heading3Icon, + Heading4Icon, + Heading5Icon, + Heading6Icon, + NumberedListIcon, + QuoteIcon, + TextIcon, +} from '@blocksuite/icons/lit'; + +export const getIcon = ( + model: BlockModel & { type?: string } +): TemplateResult => { + if (model.flavour === 'affine:paragraph') { + const type = model.type as ParagraphType; + return ( + { + text: TextIcon(), + quote: QuoteIcon(), + h1: Heading1Icon(), + h2: Heading2Icon(), + h3: Heading3Icon(), + h4: Heading4Icon(), + h5: Heading5Icon(), + h6: Heading6Icon(), + } as Record + )[type]; + } + if (model.flavour === 'affine:list') { + return ( + { + bulleted: BulletedListIcon(), + numbered: NumberedListIcon(), + todo: CheckBoxCheckLinearIcon(), + }[model.type ?? 'bulleted'] ?? BulletedListIcon() + ); + } + return TextIcon(); +}; diff --git a/packages/blocks/src/microsheet-block/components/layout.ts b/packages/blocks/src/microsheet-block/components/layout.ts new file mode 100644 index 000000000000..8d9846ecf43f --- /dev/null +++ b/packages/blocks/src/microsheet-block/components/layout.ts @@ -0,0 +1,69 @@ +import { createModal } from '@blocksuite/affine-components/context-menu'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { CloseIcon } from '@blocksuite/icons/lit'; +import { css, html, type TemplateResult } from 'lit'; +import { property } from 'lit/decorators.js'; + +export class CenterPeek extends ShadowlessElement { + static override styles = css` + center-peek { + flex-direction: column; + position: absolute; + top: 5%; + left: 5%; + width: 90%; + height: 90%; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.05); + border-radius: 12px; + } + + .side-modal-content { + flex: 1; + overflow-y: auto; + } + + .close-modal:hover { + background-color: var(--affine-hover-color); + } + .close-modal { + position: absolute; + right: -32px; + top: 0; + width: 24px; + height: 24px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + } + `; + + override render() { + return html` +
${CloseIcon()}
+ ${this.content} + `; + } + + @property({ attribute: false }) + accessor close: (() => void) | undefined = undefined; + + @property({ attribute: false }) + accessor content: TemplateResult | undefined = undefined; +} + +export const popSideDetail = (template: TemplateResult) => { + return new Promise(res => { + const modal = createModal(document.body); + const close = () => { + modal.remove(); + res(); + }; + const sideContainer = new CenterPeek(); + sideContainer.content = template; + sideContainer.close = close; + modal.onclick = e => e.target === modal && close(); + modal.append(sideContainer); + }); +}; diff --git a/packages/blocks/src/microsheet-block/components/title/index.ts b/packages/blocks/src/microsheet-block/components/title/index.ts new file mode 100644 index 000000000000..b0ed66efc9c5 --- /dev/null +++ b/packages/blocks/src/microsheet-block/components/title/index.ts @@ -0,0 +1,176 @@ +import type { RichText } from '@blocksuite/affine-components/rich-text'; +import type { InlineRange } from '@blocksuite/inline'; +import type { Text } from '@blocksuite/store'; + +import { getViewportElement } from '@blocksuite/affine-shared/utils'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { assertExists, WithDisposable } from '@blocksuite/global/utils'; +import { effect } from '@preact/signals-core'; +import { css, html } from 'lit'; +import { property, query, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; + +import type { MicrosheetBlockComponent } from '../../microsheet-block.js'; + +export class MicrosheetTitle extends WithDisposable(ShadowlessElement) { + static override styles = css` + .affine-microsheet-title { + position: relative; + flex: 1; + } + + .microsheet-title { + font-size: 20px; + font-weight: 600; + line-height: 28px; + color: var(--affine-text-primary-color); + font-family: inherit; + /* overflow-x: scroll; */ + overflow: hidden; + cursor: text; + } + + .microsheet-title [data-v-text='true'] { + display: block; + word-break: break-all !important; + } + + .microsheet-title.ellipsis [data-v-text='true'] { + white-space: nowrap !important; + text-overflow: ellipsis; + overflow: hidden; + } + + .affine-microsheet-title [data-title-empty='true']::before { + content: 'Untitled'; + position: absolute; + pointer-events: none; + color: var(--affine-text-primary-color); + } + + .affine-microsheet-title [data-title-focus='true']::before { + color: var(--affine-placeholder-color); + } + `; + + private _onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter' && !event.isComposing) { + // prevent insert v-line + event.preventDefault(); + // insert new row + this.onPressEnterKey?.(); + return; + } + }; + + get inlineEditor() { + assertExists(this.richText.inlineEditor); + return this.richText.inlineEditor; + } + + get microsheet() { + return this.closest('affine-microsheet'); + } + + get topContenteditableElement() { + return this.microsheet?.topContenteditableElement; + } + + override firstUpdated() { + // for title placeholder + this.titleText.yText.observe(() => { + this.requestUpdate(); + }); + + this.updateComplete + .then(() => { + this.disposables.add( + this.inlineEditor.slots.keydown.on(this._onKeyDown) + ); + + this.disposables.add( + this.inlineEditor.slots.inputting.on(() => { + this.isComposing = this.inlineEditor.isComposing; + }) + ); + + let beforeInlineRange: InlineRange | null = null; + this.disposables.add( + effect(() => { + const inlineRange = this.inlineEditor.inlineRange$.value; + if (inlineRange) { + if (!beforeInlineRange) { + this.isActive = true; + } + } else { + if (beforeInlineRange) { + this.isActive = false; + } + } + beforeInlineRange = inlineRange; + }) + ); + }) + .catch(console.error); + } + + override async getUpdateComplete(): Promise { + const result = await super.getUpdateComplete(); + await this.richText?.updateComplete; + return result; + } + + override render() { + const isEmpty = + (!this.titleText || !this.titleText.length) && !this.isComposing; + + const classList = classMap({ + 'microsheet-title': true, + ellipsis: !this.isActive, + }); + + return html`
+ + this.topContenteditableElement?.host + ? getViewportElement(this.topContenteditableElement.host) + : null} + class="${classList}" + data-title-empty="${isEmpty}" + data-title-focus="${this.isActive}" + data-block-is-microsheet-title="true" + title="${this.titleText.toString()}" + > +
Untitled
+
`; + } + + @state() + private accessor isActive = false; + + @state() + accessor isComposing = false; + + @property({ attribute: false }) + accessor onPressEnterKey: (() => void) | undefined = undefined; + + @property({ attribute: false }) + accessor readonly!: boolean; + + @query('rich-text') + private accessor richText!: RichText; + + @property({ attribute: false }) + accessor titleText!: Text; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-microsheet-title': MicrosheetTitle; + } +} diff --git a/packages/blocks/src/microsheet-block/config.ts b/packages/blocks/src/microsheet-block/config.ts new file mode 100644 index 000000000000..aff1e033c1ba --- /dev/null +++ b/packages/blocks/src/microsheet-block/config.ts @@ -0,0 +1,61 @@ +import type { MenuOptions } from '@blocksuite/affine-components/context-menu'; + +import { + type MicrosheetBlockModel, + MicrosheetBlockSchema, +} from '@blocksuite/affine-model'; +import { DragHandleConfigExtension } from '@blocksuite/affine-shared/services'; +import { captureEventTarget } from '@blocksuite/affine-shared/utils'; + +export interface MicrosheetOptionsConfig { + configure: (model: MicrosheetBlockModel, options: MenuOptions) => MenuOptions; +} + +let canDrop = false; +export const MicrosheetDragHandleOption = DragHandleConfigExtension({ + flavour: MicrosheetBlockSchema.model.flavour, + onDragMove: ({ state }) => { + const target = captureEventTarget(state.raw.target); + const microsheet = target?.closest('affine-microsheet'); + if (!microsheet) return false; + const view = microsheet.view; + if (view && target instanceof HTMLElement && microsheet.contains(target)) { + canDrop = view.showIndicator?.(state.raw) ?? false; + return false; + } + if (canDrop) { + view?.hideIndicator?.(); + canDrop = false; + } + return false; + }, + onDragEnd: ({ state, draggingElements, editorHost }) => { + const target = state.raw.target; + const targetEl = captureEventTarget(state.raw.target); + const microsheet = targetEl?.closest('affine-microsheet'); + if (!microsheet) { + return false; + } + const view = microsheet.view; + if ( + canDrop && + view && + view.moveTo && + target instanceof HTMLElement && + microsheet.parentElement?.contains(target) + ) { + const blocks = draggingElements.map(v => v.model); + editorHost.doc.moveBlocks(blocks, microsheet.model); + blocks.forEach(model => { + view.moveTo?.(model.id, state.raw); + }); + view.hideIndicator?.(); + return false; + } + if (canDrop) { + view?.hideIndicator?.(); + canDrop = false; + } + return false; + }, +}); diff --git a/packages/blocks/src/microsheet-block/context/host-context.ts b/packages/blocks/src/microsheet-block/context/host-context.ts new file mode 100644 index 000000000000..2b321b18d543 --- /dev/null +++ b/packages/blocks/src/microsheet-block/context/host-context.ts @@ -0,0 +1,8 @@ +import type { EditorHost } from '@blocksuite/block-std'; + +import { createContextKey } from '@blocksuite/data-view'; + +export const HostContextKey = createContextKey( + 'editor-host', + undefined +); diff --git a/packages/blocks/src/microsheet-block/data-source.ts b/packages/blocks/src/microsheet-block/data-source.ts new file mode 100644 index 000000000000..c6f14c4b61a1 --- /dev/null +++ b/packages/blocks/src/microsheet-block/data-source.ts @@ -0,0 +1,490 @@ +import type { MicrosheetBlockModel } from '@blocksuite/affine-model'; +import type { EditorHost } from '@blocksuite/block-std'; + +import { + insertPositionToIndex, + type InsertToPosition, +} from '@blocksuite/affine-shared/utils'; +import { + DataSourceBase, + type DataViewDataType, + getTagColor, + type MicrosheetFlags, + type PropertyMetaConfig, + type TType, + type ViewManager, + ViewManagerBase, + type ViewMeta, +} from '@blocksuite/data-view'; +import { propertyPresets } from '@blocksuite/data-view/property-presets'; +import { assertExists } from '@blocksuite/global/utils'; +import { type BlockModel, nanoid, Text } from '@blocksuite/store'; +import { computed, type ReadonlySignal } from '@preact/signals-core'; + +import { getIcon } from './block-icons.js'; +import { + microsheetBlockAllPropertyMap, + microsheetBlockPropertyList, + microsheetPropertyConverts, +} from './properties/index.js'; +import { titlePurePropertyConfig } from './properties/title/define.js'; +import { + addProperty, + applyCellsUpdate, + applyPropertyUpdate, + copyCellsByProperty, + deleteRows, + deleteView, + duplicateView, + findPropertyIndex, + getCell, + getProperty, + moveViewTo, + updateCell, + updateCells, + updateProperty, + updateView, +} from './utils.js'; +import { + microsheetBlockViewConverts, + microsheetBlockViewMap, + microsheetBlockViews, +} from './views/index.js'; + +export class MicrosheetBlockDataSource extends DataSourceBase { + private _batch = 0; + + private readonly _model: MicrosheetBlockModel; + + override featureFlags$: ReadonlySignal = computed(() => { + return { + enable_number_formatting: + this.doc.awarenessStore.getFlag( + 'enable_microsheet_number_formatting' + ) ?? false, + }; + }); + + properties$: ReadonlySignal = computed(() => { + return this._model.columns$.value.map(column => column.id); + }); + + readonly$: ReadonlySignal = computed(() => { + return this._model.doc.awarenessStore.isReadonly( + this._model.doc.blockCollection + ); + }); + + rows$: ReadonlySignal = computed(() => { + return this._model.children.map(v => v.id); + }); + + viewConverts = microsheetBlockViewConverts; + + viewDataList$: ReadonlySignal = computed(() => { + return this._model.views$.value as DataViewDataType[]; + }); + + override viewManager: ViewManager = new ViewManagerBase(this); + + viewMetas = microsheetBlockViews; + + get doc() { + return this._model.doc; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + get propertyMetas(): PropertyMetaConfig[] { + return microsheetBlockPropertyList; + } + + constructor(model: MicrosheetBlockModel) { + super(); + this._model = model; + } + + private _runCapture() { + if (this._batch) { + return; + } + + this._batch = requestAnimationFrame(() => { + this.doc.captureSync(); + this._batch = 0; + }); + } + + private getModelById(rowId: string): BlockModel | undefined { + return this._model.children[this._model.childMap.value.get(rowId) ?? -1]; + } + + private newPropertyName() { + let i = 1; + while ( + this._model.columns$.value.some(column => column.name === `Column ${i}`) + ) { + i++; + } + return `Column ${i}`; + } + + cellValueChange(rowId: string, propertyId: string, value: unknown): void { + this._runCapture(); + + const type = this.propertyTypeGet(propertyId); + const update = this.propertyMetaGet(type).config.valueUpdate; + let newValue = value; + if (update) { + const old = this.cellValueGet(rowId, propertyId); + newValue = update(old, this.propertyDataGet(propertyId), value); + } + if (type === 'title' && newValue instanceof Text) { + this._model.doc.transact(() => { + this._model.text?.clear(); + this._model.text?.join(newValue); + }); + return; + } + if (this._model.columns$.value.some(v => v.id === propertyId)) { + updateCell(this._model, rowId, { + columnId: propertyId, + value: newValue, + }); + applyCellsUpdate(this._model); + } + } + + cellValueGet(rowId: string, propertyId: string): unknown { + if (propertyId === 'type') { + const model = this.getModelById(rowId); + if (!model) { + return; + } + return getIcon(model); + } + const type = this.propertyTypeGet(propertyId); + if (type === 'title') { + const model = this.getModelById(rowId); + return model?.text; + } + return getCell(this._model, rowId, propertyId)?.value; + } + + propertyAdd(insertToPosition: InsertToPosition, type?: string): string { + this.doc.captureSync(); + const result = addProperty( + this._model, + insertToPosition, + microsheetBlockAllPropertyMap[ + type ?? propertyPresets.multiSelectPropertyConfig.type + ].create(this.newPropertyName()) + ); + applyPropertyUpdate(this._model); + return result; + } + + propertyDataGet(propertyId: string): Record { + return ( + this._model.columns$.value.find(v => v.id === propertyId)?.data ?? {} + ); + } + + propertyDataSet(propertyId: string, data: Record): void { + this._runCapture(); + + updateProperty(this._model, propertyId, () => ({ data })); + applyPropertyUpdate(this._model); + } + + propertyDataTypeGet(propertyId: string): TType | undefined { + const data = this._model.columns$.value.find(v => v.id === propertyId); + if (!data) { + return; + } + const meta = this.propertyMetaGet(data.type); + return meta.config.type(data); + } + + propertyDelete(id: string): void { + this.doc.captureSync(); + const index = findPropertyIndex(this._model, id); + if (index < 0) return; + + this.doc.transact(() => { + this._model.columns = this._model.columns.filter((_, i) => i !== index); + }); + } + + propertyDuplicate(propertyId: string): string { + this.doc.captureSync(); + const currentSchema = getProperty(this._model, propertyId); + assertExists(currentSchema); + const { id: copyId, ...nonIdProps } = currentSchema; + const names = new Set(this._model.columns$.value.map(v => v.name)); + let index = 1; + while (names.has(`${nonIdProps.name}(${index})`)) { + index++; + } + const schema = { ...nonIdProps, name: `${nonIdProps.name}(${index})` }; + const id = addProperty( + this._model, + { + before: false, + id: propertyId, + }, + schema + ); + copyCellsByProperty(this._model, copyId, id); + applyPropertyUpdate(this._model); + return id; + } + + propertyMetaGet(type: string): PropertyMetaConfig { + return microsheetBlockAllPropertyMap[type]; + } + + propertyNameGet(propertyId: string): string { + if (propertyId === 'type') { + return 'Block Type'; + } + return ( + this._model.columns$.value.find(v => v.id === propertyId)?.name ?? '' + ); + } + + propertyNameSet(propertyId: string, name: string): void { + this.doc.captureSync(); + updateProperty(this._model, propertyId, () => ({ name })); + applyPropertyUpdate(this._model); + } + + override propertyReadonlyGet(propertyId: string): boolean { + if (propertyId === 'type') return true; + return false; + } + + propertyTypeGet(propertyId: string): string { + if (propertyId === 'type') { + return 'image'; + } + return ( + this._model.columns$.value.find(v => v.id === propertyId)?.type ?? '' + ); + } + + propertyTypeSet(propertyId: string, toType: string): void { + const currentType = this.propertyTypeGet(propertyId); + const currentData = this.propertyDataGet(propertyId); + const rows = this.rows$.value; + const currentCells = rows.map(rowId => + this.cellValueGet(rowId, propertyId) + ); + const convertFunction = microsheetPropertyConverts.find( + v => v.from === currentType && v.to === toType + )?.convert; + const result = convertFunction?.( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + currentData as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + currentCells as any + ) ?? { + property: microsheetBlockAllPropertyMap[toType].config.defaultData(), + cells: currentCells.map(() => undefined), + }; + this.doc.captureSync(); + updateProperty(this._model, propertyId, () => ({ + type: toType, + data: result.property, + })); + const cells: Record = {}; + currentCells.forEach((value, i) => { + if (value != null || result.cells[i] != null) { + cells[rows[i]] = result.cells[i]; + } + }); + updateCells(this._model, propertyId, cells); + applyPropertyUpdate(this._model); + } + + rowAdd(insertPosition: InsertToPosition | number): string { + this.doc.captureSync(); + const index = + typeof insertPosition === 'number' + ? insertPosition + : insertPositionToIndex(insertPosition, this._model.children); + return this.doc.addBlock('affine:paragraph', {}, this._model.id, index); + } + + rowDelete(ids: string[]): void { + this.doc.captureSync(); + for (const id of ids) { + const block = this.doc.getBlock(id); + if (block) { + this.doc.deleteBlock(block.model); + } + } + deleteRows(this._model, ids); + } + + rowMove(rowId: string, position: InsertToPosition): void { + const model = this.doc.getBlockById(rowId); + if (model) { + const index = insertPositionToIndex(position, this._model.children); + const target = this._model.children[index]; + if (target?.id === rowId) { + return; + } + this.doc.moveBlocks([model], this._model, target); + } + } + + viewDataAdd(viewData: DataViewDataType): string { + this._model.doc.captureSync(); + this._model.doc.transact(() => { + this._model.views = [...this._model.views, viewData]; + }); + return viewData.id; + } + + viewDataDelete(viewId: string): void { + this._model.doc.captureSync(); + deleteView(this._model, viewId); + } + + viewDataDuplicate(id: string): string { + return duplicateView(this._model, id); + } + + viewDataGet(viewId: string): DataViewDataType { + return this.viewDataList$.value.find(data => data.id === viewId)!; + } + + viewDataMoveTo(id: string, position: InsertToPosition): void { + moveViewTo(this._model, id, position); + } + + viewDataUpdate( + id: string, + updater: (data: ViewData) => Partial + ): void { + updateView(this._model, id, updater); + } + + viewMetaGet(type: string): ViewMeta { + return microsheetBlockViewMap[type]; + } + + viewMetaGetById(viewId: string): ViewMeta { + const view = this.viewDataGet(viewId); + return this.viewMetaGet(view.mode); + } +} + +export const microsheetViewAddView = ( + model: MicrosheetBlockModel, + viewType: string +) => { + const dataSource = new MicrosheetBlockDataSource(model); + dataSource.viewManager.viewAdd(viewType); +}; +export const microsheetViewInitEmpty = ( + model: MicrosheetBlockModel, + viewType: string +) => { + addProperty( + model, + 'start', + titlePurePropertyConfig.create(titlePurePropertyConfig.config.name) + ); + microsheetViewAddView(model, viewType); +}; +export const microsheetViewInitConvert = ( + model: MicrosheetBlockModel, + viewType: string +) => { + addProperty( + model, + 'end', + propertyPresets.multiSelectPropertyConfig.create('Tag', { options: [] }) + ); + microsheetViewInitEmpty(model, viewType); +}; +export const microsheetViewInitTemplate = ( + model: MicrosheetBlockModel, + viewType: string +) => { + const ids = [nanoid(), nanoid(), nanoid()]; + const statusId = addProperty( + model, + 'end', + propertyPresets.selectPropertyConfig.create('Status', { + options: [ + { + id: ids[0], + color: getTagColor(), + value: 'TODO', + }, + { + id: ids[1], + color: getTagColor(), + value: 'In Progress', + }, + { + id: ids[2], + color: getTagColor(), + value: 'Done', + }, + ], + }) + ); + for (let i = 0; i < 4; i++) { + const rowId = model.doc.addBlock( + 'affine:paragraph', + { + text: new model.doc.Text(`Task ${i + 1}`), + }, + model.id + ); + updateCell(model, rowId, { + columnId: statusId, + value: ids[i], + }); + } + microsheetViewInitEmpty(model, viewType); +}; +export const convertToMicrosheet = (host: EditorHost, viewType: string) => { + const [_, ctx] = host.std.command + .chain() + .getSelectedModels({ + types: ['block', 'text'], + }) + .run(); + const { selectedModels } = ctx; + if (!selectedModels || selectedModels.length === 0) return; + + host.doc.captureSync(); + + const parentModel = host.doc.getParent(selectedModels[0]); + if (!parentModel) { + return; + } + + const id = host.doc.addBlock( + 'affine:microsheet', + {}, + parentModel, + parentModel.children.indexOf(selectedModels[0]) + ); + const microsheetModel = host.doc.getBlock(id)?.model as + | MicrosheetBlockModel + | undefined; + if (!microsheetModel) { + return; + } + microsheetViewInitConvert(microsheetModel, viewType); + applyPropertyUpdate(microsheetModel); + host.doc.moveBlocks(selectedModels, microsheetModel); + + const selectionManager = host.selection; + selectionManager.clear(); +}; diff --git a/packages/blocks/src/microsheet-block/detail-panel/block-renderer.ts b/packages/blocks/src/microsheet-block/detail-panel/block-renderer.ts new file mode 100644 index 000000000000..d77b4caea811 --- /dev/null +++ b/packages/blocks/src/microsheet-block/detail-panel/block-renderer.ts @@ -0,0 +1,159 @@ +import type { EditorHost } from '@blocksuite/block-std'; +import type { DetailSlotProps } from '@blocksuite/data-view'; +import type { + KanbanSingleView, + TableSingleView, +} from '@blocksuite/data-view/view-presets'; + +import { DefaultInlineManagerExtension } from '@blocksuite/affine-components/rich-text'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { css, html } from 'lit'; +import { property } from 'lit/decorators.js'; + +export class BlockRenderer + extends WithDisposable(ShadowlessElement) + implements DetailSlotProps +{ + static override styles = css` + microsheet-datasource-block-renderer { + padding-top: 36px; + padding-bottom: 16px; + display: flex; + flex-direction: column; + gap: 16px; + margin-bottom: 12px; + border-bottom: 1px solid var(--affine-border-color); + font-size: var(--affine-font-base); + line-height: var(--affine-line-height); + } + + microsheet-datasource-block-renderer .tips-placeholder { + display: none; + } + + microsheet-datasource-block-renderer rich-text { + font-size: 15px; + line-height: 24px; + } + + microsheet-datasource-block-renderer.empty rich-text::before { + content: 'Untitled'; + position: absolute; + color: var(--affine-text-disable-color); + font-size: 15px; + line-height: 24px; + user-select: none; + pointer-events: none; + } + + .microsheet-block-detail-header-icon { + width: 20px; + height: 20px; + padding: 2px; + border-radius: 4px; + background-color: var(--affine-background-secondary-color); + } + + .microsheet-block-detail-header-icon svg { + width: 16px; + height: 16px; + } + `; + + get attributeRenderer() { + return this.inlineManager.getRenderer(); + } + + get attributesSchema() { + return this.inlineManager.getSchema(); + } + + get inlineManager() { + return this.host.std.get(DefaultInlineManagerExtension.identifier); + } + + get model() { + return this.host?.doc.getBlock(this.rowId)?.model; + } + + get service() { + return this.host.std.getService('affine:microsheet'); + } + + override connectedCallback() { + super.connectedCallback(); + if (this.model && this.model.text) { + const cb = () => { + if (this.model?.text?.length == 0) { + this.classList.add('empty'); + } else { + this.classList.remove('empty'); + } + }; + this.model.text.yText.observe(cb); + this.disposables.add(() => { + this.model?.text?.yText.unobserve(cb); + }); + } + this._disposables.addFromEvent( + this, + 'keydown', + e => { + if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { + e.stopPropagation(); + e.preventDefault(); + return; + } + if ( + e.key === 'Backspace' && + !e.shiftKey && + !e.metaKey && + this.model?.text?.length === 0 + ) { + e.stopPropagation(); + e.preventDefault(); + return; + } + }, + true + ); + } + + protected override render(): unknown { + const model = this.model; + if (!model) { + return; + } + return html` + ${this.renderIcon()} + + `; + } + + renderIcon() { + const iconColumn = this.view.mainProperties$.value.iconColumn; + if (!iconColumn) { + return; + } + return html`
+ ${this.view.cellValueGet(this.rowId, iconColumn)} +
`; + } + + @property({ attribute: false }) + accessor host!: EditorHost; + + @property({ attribute: false }) + accessor rowId!: string; + + @property({ attribute: false }) + accessor view!: TableSingleView | KanbanSingleView; +} diff --git a/packages/blocks/src/microsheet-block/detail-panel/note-renderer.ts b/packages/blocks/src/microsheet-block/detail-panel/note-renderer.ts new file mode 100644 index 000000000000..ff505066c70d --- /dev/null +++ b/packages/blocks/src/microsheet-block/detail-panel/note-renderer.ts @@ -0,0 +1,129 @@ +import type { MicrosheetBlockModel } from '@blocksuite/affine-model'; +import type { DetailSlotProps, SingleView } from '@blocksuite/data-view'; + +import { focusTextModel } from '@blocksuite/affine-components/rich-text'; +import { + createDefaultDoc, + matchFlavours, +} from '@blocksuite/affine-shared/utils'; +import { + BlockStdScope, + type EditorHost, + ShadowlessElement, +} from '@blocksuite/block-std'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { css, html } from 'lit'; +import { property, query } from 'lit/decorators.js'; +export class NoteRenderer + extends WithDisposable(ShadowlessElement) + implements DetailSlotProps +{ + static override styles = css` + microsheet-datasource-note-renderer { + width: 100%; + --affine-editor-side-padding: 0; + flex: 1; + } + `; + + get microsheetBlock(): MicrosheetBlockModel { + return this.model; + } + + addNote() { + const collection = this.host?.std.collection; + if (!collection) { + return; + } + if (!this.microsheetBlock.notes) { + this.microsheetBlock.notes = {}; + } + const note = createDefaultDoc(collection); + if (note) { + this.microsheetBlock.notes[this.rowId] = note.id; + this.requestUpdate(); + requestAnimationFrame(() => { + const block = note.root?.children + .find(child => child.flavour === 'affine:note') + ?.children.find(block => + matchFlavours(block, [ + 'affine:paragraph', + 'affine:list', + 'affine:code', + ]) + ); + if (this.subHost && block) { + focusTextModel(this.subHost.std, block.id); + } + }); + } + } + + override connectedCallback() { + super.connectedCallback(); + this.microsheetBlock.propsUpdated.on(({ key }) => { + if (key === 'notes') { + this.requestUpdate(); + } + }); + } + + protected override render(): unknown { + if ( + !this.model.doc.awarenessStore.getFlag( + 'enable_microsheet_attachment_note' + ) + ) { + return null; + } + return html` +
+ ${this.renderNote()} + `; + } + + renderNote() { + const host = this.host; + const std = host?.std; + if (!std || !host) { + return; + } + const pageId = this.microsheetBlock.notes?.[this.rowId]; + if (!pageId) { + return html`
+
+ Click to add note +
+
`; + } + const page = std.collection.getDoc(pageId); + if (!page) { + return; + } + const previewStd = new BlockStdScope({ + doc: page, + extensions: std.userExtensions, + }); + return html`${previewStd.render()} `; + } + + @property({ attribute: false }) + accessor host!: EditorHost; + + @property({ attribute: false }) + accessor model!: MicrosheetBlockModel; + + @property({ attribute: false }) + accessor rowId!: string; + + @query('editor-host') + accessor subHost!: EditorHost; + + @property({ attribute: false }) + accessor view!: SingleView; +} diff --git a/packages/blocks/src/microsheet-block/index.ts b/packages/blocks/src/microsheet-block/index.ts new file mode 100644 index 000000000000..0954b9608651 --- /dev/null +++ b/packages/blocks/src/microsheet-block/index.ts @@ -0,0 +1,15 @@ +import type { MicrosheetBlockModel } from '@blocksuite/affine-model'; + +export type { MicrosheetOptionsConfig } from './config.js'; + +export * from './data-source.js'; +export * from './microsheet-block.js'; +export * from './microsheet-service.js'; +export { microsheetBlockColumns } from './properties/index.js'; +declare global { + namespace BlockSuite { + interface BlockModels { + 'affine:microsheet': MicrosheetBlockModel; + } + } +} diff --git a/packages/blocks/src/microsheet-block/microsheet-block.ts b/packages/blocks/src/microsheet-block/microsheet-block.ts new file mode 100644 index 000000000000..ed0a92169f7e --- /dev/null +++ b/packages/blocks/src/microsheet-block/microsheet-block.ts @@ -0,0 +1,412 @@ +import type { MicrosheetBlockModel } from '@blocksuite/affine-model'; + +import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption'; +import { + menu, + popMenu, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { DragIndicator } from '@blocksuite/affine-components/drag-indicator'; +import { PeekViewProvider } from '@blocksuite/affine-components/peek'; +import { toast } from '@blocksuite/affine-components/toast'; +import { NOTE_SELECTOR } from '@blocksuite/affine-shared/consts'; +import { DocModeProvider } from '@blocksuite/affine-shared/services'; +import { RANGE_SYNC_EXCLUDE_ATTR } from '@blocksuite/block-std'; +import { + createRecordDetail, + createUniComponentFromWebComponent, + DataView, + dataViewCommonStyle, + type DataViewExpose, + type DataViewProps, + type DataViewSelection, + type DataViewWidget, + type DataViewWidgetProps, + defineUniComponent, + MicrosheetSelection, + renderUniLit, + uniMap, +} from '@blocksuite/data-view'; +import { widgetPresets } from '@blocksuite/data-view/widget-presets'; +import { Rect } from '@blocksuite/global/utils'; +import { + CopyIcon, + DeleteIcon, + MoreHorizontalIcon, +} from '@blocksuite/icons/lit'; +import { Slice } from '@blocksuite/store'; +import { autoUpdate } from '@floating-ui/dom'; +import { computed, signal } from '@preact/signals-core'; +import { css, html, nothing, unsafeCSS } from 'lit'; + +import type { NoteBlockComponent } from '../note-block/index.js'; +import type { MicrosheetOptionsConfig } from './config.js'; +import type { MicrosheetBlockService } from './microsheet-service.js'; + +import { + EdgelessRootBlockComponent, + type RootService, +} from '../root-block/index.js'; +import { getDropResult } from '../root-block/widgets/drag-handle/utils.js'; +import { popSideDetail } from './components/layout.js'; +import { HostContextKey } from './context/host-context.js'; +import { MicrosheetBlockDataSource } from './data-source.js'; +import { BlockRenderer } from './detail-panel/block-renderer.js'; +import { NoteRenderer } from './detail-panel/note-renderer.js'; + +export class MicrosheetBlockComponent extends CaptionedBlockComponent< + MicrosheetBlockModel, + MicrosheetBlockService +> { + static override styles = css` + ${unsafeCSS(dataViewCommonStyle('affine-microsheet'))} + affine-microsheet { + display: block; + border-radius: 8px; + background-color: var(--affine-background-primary-color); + padding: 8px; + margin: 8px -8px -8px; + } + + .microsheet-block-selected { + background-color: var(--affine-hover-color); + border-radius: 4px; + } + + .microsheet-ops { + margin-top: 4px; + padding: 2px; + border-radius: 4px; + display: flex; + cursor: pointer; + } + + .microsheet-ops svg { + width: 16px; + height: 16px; + color: var(--affine-icon-color); + } + + .microsheet-ops:hover { + background-color: var(--affine-hover-color); + } + + @media print { + .microsheet-ops { + display: none; + } + + .microsheet-header-bar { + display: none !important; + } + } + `; + + private _clickMicrosheetOps = (e: MouseEvent) => { + const options = this.optionsConfig.configure(this.model, { + items: [ + menu.input({ + initialValue: this.model.title.toString(), + placeholder: 'Untitled', + onComplete: text => { + this.model.title.replace(0, this.model.title.length, text); + }, + }), + menu.action({ + prefix: CopyIcon(), + name: 'Copy', + select: () => { + const slice = Slice.fromModels(this.doc, [this.model]); + this.std.clipboard + .copySlice(slice) + .then(() => { + toast(this.host, 'Copied to clipboard'); + }) + .catch(console.error); + }, + }), + menu.group({ + items: [ + menu.action({ + prefix: DeleteIcon(), + class: 'delete-item', + name: 'Delete Microsheet', + select: () => { + this.model.children.slice().forEach(block => { + this.doc.deleteBlock(block); + }); + this.doc.deleteBlock(this.model); + }, + }), + ], + }), + ], + }); + + popMenu(popupTargetFromElement(e.currentTarget as HTMLElement), { + options, + }); + }; + + private _dataSource?: MicrosheetBlockDataSource; + + private dataView = new DataView(); + + private renderTitle = (dataViewMethod: DataViewExpose) => { + const addRow = () => dataViewMethod.addRow?.('start'); + return html` `; + }; + + _bindHotkey: DataViewProps['bindHotkey'] = hotkeys => { + return { + dispose: this.host.event.bindHotkey(hotkeys, { + blockId: this.topContenteditableElement?.blockId ?? this.blockId, + }), + }; + }; + + _handleEvent: DataViewProps['handleEvent'] = (name, handler) => { + return { + dispose: this.host.event.add(name, handler, { + blockId: this.blockId, + }), + }; + }; + + getRootService = () => { + return this.std.getService('affine:page'); + }; + + headerWidget: DataViewWidget = defineUniComponent( + (props: DataViewWidgetProps) => { + return html` +
+
+ ${this.renderTitle(props.viewMethods)} ${this.renderMicrosheetOps()} +
+
+
+ ${renderUniLit(widgetPresets.viewBar, props)} +
+ ${renderUniLit(this.toolsWidget, props)} +
+ ${renderUniLit(widgetPresets.filterBar, props)} +
+ `; + } + ); + + 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.doc.getBlock(id)?.model; + const target = this.doc.getBlock(result.dropBlockId)?.model ?? null; + let parent = this.doc.getParent(result.dropBlockId); + const shouldInsertIn = result.dropType === 'in'; + if (shouldInsertIn) { + parent = target; + } + if (model && target && parent) { + if (shouldInsertIn) { + this.doc.moveBlocks([model], parent); + } else { + this.doc.moveBlocks( + [model], + parent, + target, + result.dropType === 'before' + ); + } + } + }; + } + this.indicator.remove(); + return () => {}; + }; + + setSelection = (selection: DataViewSelection | undefined) => { + this.selection.setGroup( + 'note', + selection + ? [ + new MicrosheetSelection({ + blockId: this.blockId, + viewSelection: selection, + }), + ] + : [] + ); + }; + + toolsWidget: DataViewWidget = widgetPresets.createTools({ + table: [ + widgetPresets.tools.filter, + widgetPresets.tools.search, + widgetPresets.tools.viewOptions, + widgetPresets.tools.tableAddRow, + ], + kanban: [ + widgetPresets.tools.filter, + widgetPresets.tools.search, + widgetPresets.tools.viewOptions, + ], + }); + + viewSelection$ = computed(() => { + const microsheetSelection = this.selection.value.find( + (selection): selection is MicrosheetSelection => { + if (selection.blockId !== this.blockId) { + return false; + } + return selection instanceof MicrosheetSelection; + } + ); + return microsheetSelection?.viewSelection; + }); + + virtualPadding$ = signal(0); + + get dataSource(): MicrosheetBlockDataSource { + if (!this._dataSource) { + this._dataSource = new MicrosheetBlockDataSource(this.model); + this._dataSource.contextSet(HostContextKey, this.host); + } + return this._dataSource; + } + + get optionsConfig(): MicrosheetOptionsConfig { + return { + configure: (_model, options) => options, + ...this.std.getConfig('affine:page')?.microsheetOptions, + }; + } + + override get topContenteditableElement() { + if (this.rootComponent instanceof EdgelessRootBlockComponent) { + const note = this.closest(NOTE_SELECTOR); + return note; + } + return this.rootComponent; + } + + get view() { + return this.dataView.expose; + } + + private renderMicrosheetOps() { + if (this.doc.readonly) { + return nothing; + } + return html`
+ ${MoreHorizontalIcon()} +
`; + } + + override connectedCallback() { + super.connectedCallback(); + + this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true'); + this.listenFullWidthChange(); + } + + listenFullWidthChange() { + if (!this.doc.awarenessStore.getFlag('enable_microsheet_full_width')) { + return; + } + if (this.std.get(DocModeProvider).getEditorMode() === 'edgeless') { + return; + } + this.disposables.add( + autoUpdate(this.host, this, () => { + const padding = + this.getBoundingClientRect().left - + this.host.getBoundingClientRect().left; + this.virtualPadding$.value = Math.max(0, padding - 72); + }) + ); + } + + override renderBlock() { + const peekViewService = this.std.getOptional(PeekViewProvider); + return html` +
+ ${this.dataView.render({ + virtualPadding$: this.virtualPadding$, + bindHotkey: this._bindHotkey, + handleEvent: this._handleEvent, + selection$: this.viewSelection$, + setSelection: this.setSelection, + dataSource: this.dataSource, + headerWidget: this.headerWidget, + onDrag: this.onDrag, + std: this.std, + detailPanelConfig: { + openDetailPanel: (target, data) => { + const template = createRecordDetail({ + ...data, + detail: { + header: uniMap( + createUniComponentFromWebComponent(BlockRenderer), + props => ({ + ...props, + host: this.host, + }) + ), + note: uniMap( + createUniComponentFromWebComponent(NoteRenderer), + props => ({ + ...props, + model: this.model, + host: this.host, + }) + ), + }, + }); + if (peekViewService) { + return peekViewService.peek({ + target, + template, + }); + } else { + return popSideDetail(template); + } + }, + }, + })} +
+ `; + } + + override accessor useZeroWidth = true; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-microsheet': MicrosheetBlockComponent; + } +} diff --git a/packages/blocks/src/microsheet-block/microsheet-service.ts b/packages/blocks/src/microsheet-block/microsheet-service.ts new file mode 100644 index 000000000000..292e610e119f --- /dev/null +++ b/packages/blocks/src/microsheet-block/microsheet-service.ts @@ -0,0 +1,60 @@ +import type { BlockModel, Doc } from '@blocksuite/store'; + +import { + type MicrosheetBlockModel, + MicrosheetBlockSchema, +} from '@blocksuite/affine-model'; +import { BlockService } from '@blocksuite/block-std'; +import { viewPresets } from '@blocksuite/data-view/view-presets'; + +import { + microsheetViewAddView, + microsheetViewInitEmpty, + microsheetViewInitTemplate, +} from './data-source.js'; +import { + addProperty, + applyPropertyUpdate, + updateCell, + updateView, +} from './utils.js'; + +export class MicrosheetBlockService extends BlockService { + static override readonly flavour = MicrosheetBlockSchema.model.flavour; + + addColumn = addProperty; + + applyColumnUpdate = applyPropertyUpdate; + + microsheetViewAddView = microsheetViewAddView; + + microsheetViewInitEmpty = microsheetViewInitEmpty; + + updateCell = updateCell; + + updateView = updateView; + + viewPresets = viewPresets; + + initMicrosheetBlock( + doc: Doc, + model: BlockModel, + microsheetId: string, + viewType: string, + isAppendNewRow = true + ) { + const blockModel = doc.getBlock(microsheetId)?.model as + | MicrosheetBlockModel + | undefined; + if (!blockModel) { + return; + } + microsheetViewInitTemplate(blockModel, viewType); + if (isAppendNewRow) { + const parent = doc.getParent(model); + if (!parent) return; + doc.addBlock('affine:paragraph', {}, parent.id); + } + applyPropertyUpdate(blockModel); + } +} diff --git a/packages/blocks/src/microsheet-block/microsheet-spec.ts b/packages/blocks/src/microsheet-block/microsheet-spec.ts new file mode 100644 index 000000000000..537e48a4f39e --- /dev/null +++ b/packages/blocks/src/microsheet-block/microsheet-spec.ts @@ -0,0 +1,18 @@ +import { + BlockViewExtension, + type ExtensionType, + FlavourExtension, +} from '@blocksuite/block-std'; +import { MicrosheetSelectionExtension } from '@blocksuite/data-view'; +import { literal } from 'lit/static-html.js'; + +import { MicrosheetDragHandleOption } from './config.js'; +import { MicrosheetBlockService } from './microsheet-service.js'; + +export const MicrosheetBlockSpec: ExtensionType[] = [ + FlavourExtension('affine:microsheet'), + MicrosheetBlockService, + BlockViewExtension('affine:microsheet', literal`affine-microsheet`), + MicrosheetDragHandleOption, + MicrosheetSelectionExtension, +]; diff --git a/packages/blocks/src/microsheet-block/properties/converts.ts b/packages/blocks/src/microsheet-block/properties/converts.ts new file mode 100644 index 000000000000..7a82b32ca8cc --- /dev/null +++ b/packages/blocks/src/microsheet-block/properties/converts.ts @@ -0,0 +1,177 @@ +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 microsheetPropertyConverts = [ + ...presetPropertyConverts, + createPropertyConvert( + richTextColumnModelConfig, + propertyModelPresets.selectPropertyModelConfig, + (_property, cells) => { + const options: Record = {}; + 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 = {}; + 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 = ['yes', 'true']; + return { + property: {}, + cells: cells.map(v => + v && truthyValues.includes(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), + }; + } + ), +]; diff --git a/packages/blocks/src/microsheet-block/properties/index.ts b/packages/blocks/src/microsheet-block/properties/index.ts new file mode 100644 index 000000000000..855625f45ad9 --- /dev/null +++ b/packages/blocks/src/microsheet-block/properties/index.ts @@ -0,0 +1,41 @@ +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 microsheetBlockColumns = { + checkboxColumnConfig: checkboxPropertyConfig, + dateColumnConfig: datePropertyConfig, + multiSelectColumnConfig: multiSelectPropertyConfig, + numberColumnConfig: numberPropertyConfig, + progressColumnConfig: progressPropertyConfig, + selectColumnConfig: selectPropertyConfig, + linkColumnConfig, + richTextColumnConfig, +}; +export const microsheetBlockPropertyList = Object.values( + microsheetBlockColumns +); +export const microsheetBlockHiddenColumns = [ + propertyPresets.imagePropertyConfig, + titleColumnConfig, +]; +const microsheetBlockAllColumns = [ + ...microsheetBlockPropertyList, + ...microsheetBlockHiddenColumns, +]; +export const microsheetBlockAllPropertyMap = Object.fromEntries( + microsheetBlockAllColumns.map(v => [v.type, v as PropertyMetaConfig]) +); diff --git a/packages/blocks/src/microsheet-block/properties/link/cell-renderer.ts b/packages/blocks/src/microsheet-block/properties/link/cell-renderer.ts new file mode 100644 index 000000000000..7362e176a3d0 --- /dev/null +++ b/packages/blocks/src/microsheet-block/properties/link/cell-renderer.ts @@ -0,0 +1,254 @@ +import { RefNodeSlotsProvider } from '@blocksuite/affine-components/rich-text'; +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 { PenIcon } from '@blocksuite/icons/lit'; +import { baseTheme } from '@toeverything/theme'; +import { css, 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 { + static override styles = css` + affine-microsheet-link-cell { + width: 100%; + user-select: none; + } + + affine-microsheet-link-cell:hover .affine-microsheet-link-icon { + visibility: visible; + } + + .affine-microsheet-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-microsheet-link-node { + flex: 1; + word-break: break-all; + } + + .affine-microsheet-link-icon { + position: absolute; + right: 0; + display: flex; + align-items: center; + visibility: hidden; + cursor: pointer; + background: var(--affine-background-primary-color); + border-radius: 4px; + } + .affine-microsheet-link-icon:hover { + background: var(--affine-hover-color); + } + + .affine-microsheet-link-icon svg { + width: 16px; + height: 16px; + fill: var(--affine-icon-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 _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('.link-node'); + if (link) { + event.preventDefault(); + link.click(); + } + return; + } + }; + + private _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` + + `; + } + + 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; + } + const result = std + ?.getOptional(ParseDocUrlProvider) + ?.parseDocUrl(this.value); + if (result) { + this.docId = result.docId; + } else { + this.docId = undefined; + } + } + } + + @state() + accessor docId: string | undefined = undefined; +} + +export class LinkCellEditing extends BaseCellRenderer { + static override styles = css` + affine-microsheet-link-cell-editing { + width: 100%; + cursor: text; + } + + .affine-microsheet-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-microsheet-link-editing:focus { + outline: none; + } + `; + + private _focusEnd = () => { + const end = this._container.value.length; + this._container.focus(); + this._container.setSelectionRange(end, end); + }; + + private _onKeydown = (e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.isComposing) { + this._setValue(); + setTimeout(() => { + this.selectCurrentCell(false); + }); + } + }; + + private _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``; + } + + @query('.affine-microsheet-link-editing') + private accessor _container!: HTMLInputElement; +} + +export const linkColumnConfig = linkColumnModelConfig.createPropertyMeta({ + icon: createIcon('LinkIcon'), + cellRenderer: { + view: createFromBaseCellRenderer(LinkCell), + edit: createFromBaseCellRenderer(LinkCellEditing), + }, +}); diff --git a/packages/blocks/src/microsheet-block/properties/link/components/link-node.ts b/packages/blocks/src/microsheet-block/properties/link/components/link-node.ts new file mode 100644 index 000000000000..9566173585ee --- /dev/null +++ b/packages/blocks/src/microsheet-block/properties/link/components/link-node.ts @@ -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`${this.link}`; + } + + return html`${this.link}`; + } + + @property({ attribute: false }) + accessor link!: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-microsheet-link-node': LinkNode; + } +} diff --git a/packages/blocks/src/microsheet-block/properties/link/define.ts b/packages/blocks/src/microsheet-block/properties/link/define.ts new file mode 100644 index 000000000000..aabe3790432d --- /dev/null +++ b/packages/blocks/src/microsheet-block/properties/link/define.ts @@ -0,0 +1,16 @@ +import { propertyType, tString } from '@blocksuite/data-view'; + +export const linkColumnType = propertyType('link'); +export const linkColumnModelConfig = linkColumnType.modelConfig({ + name: 'Link', + type: () => tString.create(), + defaultData: () => ({}), + cellToString: data => data?.toString() ?? '', + cellFromString: data => { + return { + value: data, + }; + }, + cellToJson: data => data ?? null, + isEmpty: data => data == null || data.length == 0, +}); diff --git a/packages/blocks/src/microsheet-block/properties/rich-text/cell-renderer.ts b/packages/blocks/src/microsheet-block/properties/rich-text/cell-renderer.ts new file mode 100644 index 000000000000..2429e97d7c7b --- /dev/null +++ b/packages/blocks/src/microsheet-block/properties/rich-text/cell-renderer.ts @@ -0,0 +1,395 @@ +import { + type AffineInlineEditor, + type AffineTextAttributes, + DefaultInlineManagerExtension, + type RichText, +} from '@blocksuite/affine-components/rich-text'; +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 type { MicrosheetBlockComponent } from '../../microsheet-block.js'; + +import { HostContextKey } from '../../context/host-context.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)[k] + ) { + return [k, !v]; + } else { + return [k, v]; + } + }) + ); + + inlineEditor.formatText(inlineRange, newAttributes, { + mode: 'merge', + }); + root.blur(); + + inlineEditor.syncInlineRange(); +} + +export class RichTextCell extends BaseCellRenderer { + static override styles = css` + affine-microsheet-rich-text-cell { + display: flex; + align-items: center; + width: 100%; + user-select: none; + } + + .affine-microsheet-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-microsheet-rich-text v-line { + display: flex !important; + align-items: center; + height: 100%; + width: 100%; + } + + .affine-microsheet-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:microsheet'); + } + + get topContenteditableElement() { + const microsheetBlock = + this.closest('affine-microsheet'); + return microsheetBlock?.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`
`; + } + return keyed( + this.value, + html`` + ); + } + + override updated(changedProperties: PropertyValues) { + if (changedProperties.has('readonly')) { + this.changeUserSelectAccordToReadOnly(); + } + } + + @query('rich-text') + private accessor _richTextElement: RichText | null = null; +} + +export class RichTextCellEditing extends BaseCellRenderer { + static override styles = css` + affine-microsheet-rich-text-cell-editing { + display: flex; + align-items: center; + width: 100%; + min-width: 1px; + cursor: text; + } + + .affine-microsheet-rich-text { + display: flex; + flex-direction: column; + justify-content: center; + width: 100%; + height: 100%; + outline: none; + } + + .affine-microsheet-rich-text v-line { + display: flex !important; + align-items: center; + height: 100%; + width: 100%; + } + + .affine-microsheet-rich-text v-line > div { + flex-grow: 1; + } + `; + + private _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 _initYText = (text?: string) => { + const yText = new Text(text); + this.onChange(yText); + }; + + private _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(); + } + + 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:microsheet'); + } + + get topContenteditableElement() { + const microsheetBlock = + this.closest('affine-microsheet'); + return microsheetBlock?.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` + this.topContenteditableElement?.host + ? getViewportElement(this.topContenteditableElement.host) + : null} + class="affine-microsheet-rich-text inline-editor" + >`; + } + + @query('rich-text') + private accessor _richTextElement: RichText | null = null; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-microsheet-rich-text-cell-editing': RichTextCellEditing; + } +} + +export const richTextColumnConfig = + richTextColumnModelConfig.createPropertyMeta({ + icon: createIcon('TextIcon'), + + cellRenderer: { + view: createFromBaseCellRenderer(RichTextCell), + edit: createFromBaseCellRenderer(RichTextCellEditing), + }, + }); diff --git a/packages/blocks/src/microsheet-block/properties/rich-text/define.ts b/packages/blocks/src/microsheet-block/properties/rich-text/define.ts new file mode 100644 index 000000000000..15efce19a29f --- /dev/null +++ b/packages/blocks/src/microsheet-block/properties/rich-text/define.ts @@ -0,0 +1,32 @@ +import { propertyType, tRichText } 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({ + name: 'Text', + type: () => tRichText.create(), + defaultData: () => ({}), + cellToString: data => data?.toString() ?? '', + cellFromString: data => { + return { + value: new Text(data), + }; + }, + cellToJson: data => data?.toString() ?? null, + onUpdate: (value, _data, callback) => { + const yText = toYText(value); + yText.observe(callback); + callback(); + return { + dispose: () => { + yText.unobserve(callback); + }, + }; + }, + isEmpty: data => data == null || data.length === 0, + values: data => (data?.toString() ? [data.toString()] : []), + }); diff --git a/packages/blocks/src/microsheet-block/properties/title/cell-renderer.ts b/packages/blocks/src/microsheet-block/properties/title/cell-renderer.ts new file mode 100644 index 000000000000..5bf879899bce --- /dev/null +++ b/packages/blocks/src/microsheet-block/properties/title/cell-renderer.ts @@ -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, + }) + ), + }, +}); diff --git a/packages/blocks/src/microsheet-block/properties/title/define.ts b/packages/blocks/src/microsheet-block/properties/title/define.ts new file mode 100644 index 000000000000..911348ca86d3 --- /dev/null +++ b/packages/blocks/src/microsheet-block/properties/title/define.ts @@ -0,0 +1,41 @@ +import type { Text } from '@blocksuite/store'; + +import { propertyType, tRichText } from '@blocksuite/data-view'; + +export const titleColumnType = propertyType('title'); + +export const titlePurePropertyConfig = titleColumnType.modelConfig({ + name: 'Title', + type: () => tRichText.create(), + defaultData: () => ({}), + cellToString: data => data?.toString() ?? '', + cellFromString: data => { + return { + value: data, + }; + }, + cellToJson: data => data?.toString() ?? null, + onUpdate: (value, _data, callback) => { + value.yText.observe(callback); + callback(); + return { + dispose: () => { + value.yText.unobserve(callback); + }, + }; + }, + valueUpdate: (value, _data, 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: data => data == null || data.length === 0, + values: data => (data?.toString() ? [data.toString()] : []), +}); diff --git a/packages/blocks/src/microsheet-block/properties/title/icon.ts b/packages/blocks/src/microsheet-block/properties/title/icon.ts new file mode 100644 index 000000000000..f38dcbe3ceae --- /dev/null +++ b/packages/blocks/src/microsheet-block/properties/title/icon.ts @@ -0,0 +1,21 @@ +import { BaseCellRenderer } from '@blocksuite/data-view'; +import { css, html } from 'lit'; + +export class IconCell extends BaseCellRenderer { + static override styles = css` + affine-microsheet-image-cell { + width: 100%; + height: 100%; + display: flex; + align-items: center; + } + affine-microsheet-image-cell img { + width: 20px; + height: 20px; + } + `; + + override render() { + return html``; + } +} diff --git a/packages/blocks/src/microsheet-block/properties/title/text.ts b/packages/blocks/src/microsheet-block/properties/title/text.ts new file mode 100644 index 000000000000..537b7c874f47 --- /dev/null +++ b/packages/blocks/src/microsheet-block/properties/title/text.ts @@ -0,0 +1,319 @@ +import type { Text } from '@blocksuite/store'; + +import { + DefaultInlineManagerExtension, + type RichText, +} from '@blocksuite/affine-components/rich-text'; +import { ParseDocUrlProvider } 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 { effect } from '@preact/signals-core'; +import { css } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { html } from 'lit/static-html.js'; + +import type { MicrosheetBlockComponent } from '../../microsheet-block.js'; + +import { HostContextKey } from '../../context/host-context.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 { + static override styles = styles; + + get attributeRenderer() { + return this.inlineManager?.getRenderer(); + } + + get attributesSchema() { + return this.inlineManager?.getSchema(); + } + + get inlineEditor() { + assertExists(this.richText); + const inlineEditor = this.richText.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:microsheet'); + } + + get topContenteditableElement() { + const microsheetBlock = + this.closest('affine-microsheet'); + return microsheetBlock?.topContenteditableElement; + } + + renderIcon() { + 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`
${icon}
`; + } + + @query('rich-text') + accessor richText!: RichText; + + @property({ attribute: false }) + accessor showIcon = false; +} + +export class HeaderAreaTextCell extends BaseTextCell { + override render() { + return html`${this.renderIcon()} + `; + } +} + +export class HeaderAreaTextCellEditing extends BaseTextCell { + private _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 _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 _onPaste = (e: ClipboardEvent) => { + const inlineEditor = this.inlineEditor; + assertExists(inlineEditor); + + const inlineRange = inlineEditor.getInlineRange(); + if (!inlineRange) return; + + 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, + }); + } 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, + }); + } + }; + + private get std() { + const host = this.view.contextGet(HostContextKey); + return 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) { + super.firstUpdated(props); + this.disposables.addFromEvent(this.richText, 'copy', this._onCopy); + this.disposables.addFromEvent(this.richText, 'cut', this._onCut); + this.disposables.addFromEvent(this.richText, 'paste', e => { + this._onPaste(e); + }); + 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 render() { + return html`${this.renderIcon()} + + this.topContenteditableElement?.host + ? getViewportElement(this.topContenteditableElement.host) + : null} + class="data-view-header-area-rich-text can-link-doc" + >`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'data-view-header-area-text': HeaderAreaTextCell; + 'data-view-header-area-text-editing': HeaderAreaTextCellEditing; + } +} diff --git a/packages/blocks/src/microsheet-block/properties/utils.ts b/packages/blocks/src/microsheet-block/properties/utils.ts new file mode 100644 index 000000000000..851c5eb61aec --- /dev/null +++ b/packages/blocks/src/microsheet-block/properties/utils.ts @@ -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; +}; diff --git a/packages/blocks/src/microsheet-block/utils.ts b/packages/blocks/src/microsheet-block/utils.ts new file mode 100644 index 000000000000..54366f30449a --- /dev/null +++ b/packages/blocks/src/microsheet-block/utils.ts @@ -0,0 +1,240 @@ +import type { + Cell, + Column, + ColumnUpdater, + MicrosheetBlockModel, + ViewBasicDataType, +} from '@blocksuite/affine-model'; +import type { BlockModel } from '@blocksuite/store'; + +import { + arrayMove, + insertPositionToIndex, + type InsertToPosition, +} from '@blocksuite/affine-shared/utils'; + +export function addProperty( + model: MicrosheetBlockModel, + position: InsertToPosition, + column: Omit & { + id?: string; + } +): string { + const id = column.id ?? model.doc.generateBlockId(); + if (model.columns.some(v => v.id === id)) { + return id; + } + model.doc.transact(() => { + const col: Column = { + ...column, + id, + }; + model.columns.splice( + insertPositionToIndex(position, model.columns), + 0, + col + ); + }); + return id; +} + +export function applyCellsUpdate(model: MicrosheetBlockModel) { + model.doc.updateBlock(model, { + cells: model.cells, + }); +} + +export function applyPropertyUpdate(model: MicrosheetBlockModel) { + model.doc.updateBlock(model, { + columns: model.columns, + }); +} + +export function applyViewsUpdate(model: MicrosheetBlockModel) { + model.doc.updateBlock(model, { + views: model.views, + }); +} + +export function copyCellsByProperty( + model: MicrosheetBlockModel, + fromId: Column['id'], + toId: Column['id'] +) { + model.doc.transact(() => { + Object.keys(model.cells).forEach(rowId => { + const cell = model.cells[rowId][fromId]; + if (cell) { + model.cells[rowId][toId] = { + ...cell, + columnId: toId, + }; + } + }); + }); +} + +export function deleteColumn( + model: MicrosheetBlockModel, + columnId: Column['id'] +) { + const index = findPropertyIndex(model, columnId); + if (index < 0) return; + + model.doc.transact(() => { + model.columns.splice(index, 1); + }); +} + +export function deleteRows(model: MicrosheetBlockModel, rowIds: string[]) { + model.doc.transact(() => { + for (const rowId of rowIds) { + delete model.cells[rowId]; + } + }); +} + +export function deleteView(model: MicrosheetBlockModel, id: string) { + model.doc.captureSync(); + model.doc.transact(() => { + model.views = model.views.filter(v => v.id !== id); + }); +} + +export function duplicateView(model: MicrosheetBlockModel, id: string): string { + const newId = model.doc.generateBlockId(); + model.doc.transact(() => { + const index = model.views.findIndex(v => v.id === id); + const view = model.views[index]; + if (view) { + model.views.splice( + index + 1, + 0, + JSON.parse(JSON.stringify({ ...view, id: newId })) + ); + } + }); + return newId; +} + +export function findPropertyIndex( + model: MicrosheetBlockModel, + id: Column['id'] +) { + return model.columns.findIndex(v => v.id === id); +} + +export function getCell( + model: MicrosheetBlockModel, + rowId: BlockModel['id'], + columnId: Column['id'] +): Cell | null { + if (columnId === 'title') { + return { + columnId: 'title', + value: rowId, + }; + } + const yRow = model.cells$.value[rowId]; + const yCell = yRow?.[columnId] ?? null; + if (!yCell) return null; + + return { + columnId: yCell.columnId, + value: yCell.value, + }; +} + +export function getProperty( + model: MicrosheetBlockModel, + id: Column['id'] +): Column | undefined { + return model.columns.find(v => v.id === id); +} + +export function moveViewTo( + model: MicrosheetBlockModel, + id: string, + position: InsertToPosition +) { + model.doc.transact(() => { + model.views = arrayMove( + model.views, + v => v.id === id, + arr => insertPositionToIndex(position, arr) + ); + }); + applyViewsUpdate(model); +} + +export function updateCell( + model: MicrosheetBlockModel, + rowId: string, + cell: Cell +) { + const hasRow = rowId in model.cells; + if (!hasRow) { + model.cells[rowId] = Object.create(null); + } + model.doc.transact(() => { + model.cells[rowId][cell.columnId] = { + columnId: cell.columnId, + value: cell.value, + }; + }); +} + +export function updateCells( + model: MicrosheetBlockModel, + columnId: string, + cells: Record +) { + model.doc.transact(() => { + Object.entries(cells).forEach(([rowId, value]) => { + if (!model.cells[rowId]) { + model.cells[rowId] = Object.create(null); + } + model.cells[rowId][columnId] = { + columnId, + value, + }; + }); + }); +} + +export function updateProperty( + model: MicrosheetBlockModel, + id: string, + updater: ColumnUpdater +) { + const index = model.columns.findIndex(v => v.id === id); + if (index == null) { + return; + } + model.doc.transact(() => { + const column = model.columns[index]; + const result = updater(column); + model.columns[index] = { ...column, ...result }; + }); + return id; +} + +export const updateView = ( + model: MicrosheetBlockModel, + id: string, + update: (data: ViewData) => Partial +) => { + model.doc.transact(() => { + model.views = model.views.map(v => { + if (v.id !== id) { + return v; + } + return { ...v, ...update(v as ViewData) }; + }); + }); + applyViewsUpdate(model); +}; +export const MICROSHEET_CONVERT_WHITE_LIST = [ + 'affine:list', + 'affine:paragraph', +]; diff --git a/packages/blocks/src/microsheet-block/views/index.ts b/packages/blocks/src/microsheet-block/views/index.ts new file mode 100644 index 000000000000..253af61c4370 --- /dev/null +++ b/packages/blocks/src/microsheet-block/views/index.ts @@ -0,0 +1,13 @@ +import type { ViewMeta } from '@blocksuite/data-view'; + +import { viewConverts, viewPresets } from '@blocksuite/data-view/view-presets'; + +export const microsheetBlockViews: ViewMeta[] = [ + viewPresets.tableViewMeta, + viewPresets.kanbanViewMeta, +]; + +export const microsheetBlockViewMap = Object.fromEntries( + microsheetBlockViews.map(view => [view.type, view]) +); +export const microsheetBlockViewConverts = [...viewConverts]; diff --git a/packages/blocks/src/microsheet-block/widgets/index.ts b/packages/blocks/src/microsheet-block/widgets/index.ts new file mode 100644 index 000000000000..75e9c52b6c7b --- /dev/null +++ b/packages/blocks/src/microsheet-block/widgets/index.ts @@ -0,0 +1 @@ +export const commonTools = []; diff --git a/packages/blocks/src/root-block/widgets/slash-menu/config.ts b/packages/blocks/src/root-block/widgets/slash-menu/config.ts index 7a53fefd8775..732c82b9429f 100644 --- a/packages/blocks/src/root-block/widgets/slash-menu/config.ts +++ b/packages/blocks/src/root-block/widgets/slash-menu/config.ts @@ -59,6 +59,7 @@ import { formatDate, formatTime } from '../../utils/misc.js'; import { type SlashMenuTooltip, slashMenuToolTips } from './tooltips/index.js'; import { createConversionItem, + createMicrosheetBlockInNextLine, createTextFormatItem, insideEdgelessText, tryRemoveEmptyLine, @@ -546,6 +547,32 @@ export const defaultSlashMenuConfig: SlashMenuConfig = { // --------------------------------------------------------- { groupName: 'Database' }, + { + name: 'Table', + description: 'Display items in a table format.', + alias: ['table'], + icon: DatabaseTableViewIcon20, + tooltip: slashMenuToolTips['Table'], + showWhen: ({ model }) => + model.doc.schema.flavourSchemaMap.has('affine:microsheet') && + !insideEdgelessText(model), + action: ({ rootComponent, model }) => { + const id = createMicrosheetBlockInNextLine(model); + if (!id) { + return; + } + const service = rootComponent.std.getService('affine:microsheet'); + if (!service) return; + service.initMicrosheetBlock( + rootComponent.doc, + model, + id, + viewPresets.tableViewMeta.type, + false + ); + tryRemoveEmptyLine(model); + }, + }, { name: 'Table View', description: 'Display items in a table format.', diff --git a/packages/blocks/src/root-block/widgets/slash-menu/utils.ts b/packages/blocks/src/root-block/widgets/slash-menu/utils.ts index 27c8446fe3bc..db14bdd023a5 100644 --- a/packages/blocks/src/root-block/widgets/slash-menu/utils.ts +++ b/packages/blocks/src/root-block/widgets/slash-menu/utils.ts @@ -84,6 +84,34 @@ export function insideEdgelessText(model: BlockModel) { return isInsideBlockByFlavour(model.doc, model, 'affine:edgeless-text'); } +export function createDatabaseBlockInNextLine(model: BlockModel) { + let parent = model.doc.getParent(model); + while (parent && parent.flavour !== 'affine:note') { + model = parent; + parent = model.doc.getParent(parent); + } + if (!parent) { + return; + } + const index = parent.children.indexOf(model); + + return model.doc.addBlock('affine:database', {}, parent, index + 1); +} + +export function createMicrosheetBlockInNextLine(model: BlockModel) { + let parent = model.doc.getParent(model); + while (parent && parent.flavour !== 'affine:note') { + model = parent; + parent = model.doc.getParent(parent); + } + if (!parent) { + return; + } + const index = parent.children.indexOf(model); + + return model.doc.addBlock('affine:microsheet', {}, parent, index + 1); +} + export function tryRemoveEmptyLine(model: BlockModel) { if (model.text?.length === 0) { model.doc.deleteBlock(model); diff --git a/packages/blocks/src/schemas.ts b/packages/blocks/src/schemas.ts index 0fb0cf9a702e..625d72b08b85 100644 --- a/packages/blocks/src/schemas.ts +++ b/packages/blocks/src/schemas.ts @@ -21,6 +21,7 @@ import { ImageBlockSchema, LatexBlockSchema, ListBlockSchema, + MicrosheetBlockSchema, NoteBlockSchema, ParagraphBlockSchema, RootBlockSchema, @@ -42,6 +43,7 @@ export const AffineSchemas: z.infer[] = [ BookmarkBlockSchema, FrameBlockSchema, DatabaseBlockSchema, + MicrosheetBlockSchema, SurfaceRefBlockSchema, DataViewBlockSchema, AttachmentBlockSchema, From b2ab68013a17da1cfdffd9f3d4530f1dfd9fcc41 Mon Sep 17 00:00:00 2001 From: "caojiafu@cvte.com" Date: Wed, 23 Oct 2024 09:52:35 +0800 Subject: [PATCH 02/13] feat(blocks): microsheet-block init --- .../src/core/common/selection-schema.ts | 2 - .../affine/microsheet-data-view/CHANGELOG.md | 417 ++++++ .../affine/microsheet-data-view/package.json | 85 ++ .../src/core/common/ast.ts | 119 ++ .../common/component/overflow/overflow.ts | 107 ++ .../src/core/common/css-variable.ts | 60 + .../src/core/common/data-source/base.ts | 224 ++++ .../src/core/common/data-source/context.ts | 12 + .../src/core/common/data-source/index.ts | 2 + .../src/core/common/detail/detail.ts | 282 ++++ .../src/core/common/detail/field.ts | 290 +++++ .../src/core/common/detail/selection.ts | 158 +++ .../src/core/common/group-by.ts | 19 + .../src/core/common/group-by/define.ts | 171 +++ .../src/core/common/group-by/group-title.ts | 120 ++ .../src/core/common/group-by/helper.ts | 280 ++++ .../src/core/common/group-by/matcher.ts | 6 + .../src/core/common/group-by/renderer/base.ts | 25 + .../common/group-by/renderer/boolean-group.ts | 25 + .../common/group-by/renderer/number-group.ts | 65 + .../common/group-by/renderer/select-group.ts | 119 ++ .../common/group-by/renderer/string-group.ts | 53 + .../src/core/common/group-by/setting.ts | 306 +++++ .../src/core/common/group-by/types.ts | 32 + .../src/core/common/index.ts | 10 + .../src/core/common/literal/define.ts | 186 +++ .../src/core/common/literal/matcher.ts | 36 + .../common/literal/renderer/array-literal.ts | 11 + .../common/literal/renderer/date-literal.ts | 12 + .../literal/renderer/literal-element.ts | 67 + .../common/literal/renderer/tag-literal.ts | 74 ++ .../common/literal/renderer/union-string.ts | 11 + .../src/core/common/literal/types.ts | 15 + .../src/core/common/popup.ts | 31 + .../src/core/common/properties.ts | 267 ++++ .../src/core/common/property-menu.ts | 49 + .../src/core/common/ref/ref.ts | 161 +++ .../src/core/common/selection.ts | 134 ++ .../src/core/common/stats/any.ts | 98 ++ .../src/core/common/stats/checkbox.ts | 62 + .../src/core/common/stats/index.ts | 11 + .../src/core/common/stats/number.ts | 116 ++ .../src/core/common/stats/type.ts | 17 + .../src/core/common/types.ts | 23 + .../src/core/data-view.ts | 221 ++++ .../microsheet-data-view/src/core/index.ts | 11 + .../src/core/logical/data-type.ts | 44 + .../src/core/logical/eval-filter.ts | 64 + .../src/core/logical/index.ts | 2 + .../src/core/logical/matcher.ts | 71 + .../src/core/logical/property-matcher.ts | 102 ++ .../src/core/logical/typesystem.ts | 292 +++++ .../src/core/property/base-cell.ts | 114 ++ .../src/core/property/convert.ts | 32 + .../src/core/property/index.ts | 6 + .../src/core/property/manager.ts | 38 + .../src/core/property/property-config.ts | 72 ++ .../src/core/property/renderer.ts | 25 + .../src/core/property/types.ts | 43 + .../microsheet-data-view/src/core/types.ts | 26 + .../src/core/utils/drag.ts | 54 + .../src/core/utils/event.ts | 3 + .../src/core/utils/frame-loop.ts | 93 ++ .../src/core/utils/index.ts | 3 + .../src/core/utils/menu-title.ts | 23 + .../src/core/utils/tags/colors.ts | 64 + .../src/core/utils/tags/index.ts | 3 + .../src/core/utils/tags/multi-tag-select.ts | 503 ++++++++ .../src/core/utils/tags/multi-tag-view.ts | 82 ++ .../src/core/utils/tags/styles.ts | 200 +++ .../src/core/utils/uni-component/index.ts | 2 + .../src/core/utils/uni-component/operation.ts | 17 + .../utils/uni-component/render-template.ts | 25 + .../core/utils/uni-component/uni-component.ts | 161 +++ .../src/core/utils/uni-icon.ts | 36 + .../src/core/utils/utils.ts | 39 + .../src/core/view-manager/cell.ts | 79 ++ .../src/core/view-manager/index.ts | 2 + .../src/core/view-manager/property.ts | 169 +++ .../src/core/view-manager/row.ts | 23 + .../src/core/view-manager/single-view.ts | 425 ++++++ .../src/core/view-manager/view-manager.ts | 128 ++ .../src/core/view/convert.ts | 27 + .../src/core/view/data-view-base.ts | 17 + .../src/core/view/data-view.ts | 77 ++ .../src/core/view/index.ts | 3 + .../src/core/view/types.ts | 54 + .../src/core/widget/index.ts | 1 + .../src/core/widget/types.ts | 9 + .../src/core/widget/widget-base.ts | 26 + .../microsheet-data-view/src/effects.ts | 264 ++++ .../affine/microsheet-data-view/src/index.ts | 1 + .../checkbox/cell-renderer.ts | 121 ++ .../src/property-presets/checkbox/define.ts | 19 + .../src/property-presets/converts.ts | 45 + .../property-presets/date/cell-renderer.ts | 151 +++ .../src/property-presets/date/define.ts | 19 + .../property-presets/image/cell-renderer.ts | 32 + .../src/property-presets/image/define.ts | 18 + .../src/property-presets/index.ts | 22 + .../multi-select/cell-renderer.ts | 97 ++ .../property-presets/multi-select/define.ts | 70 + .../property-presets/number/cell-renderer.ts | 194 +++ .../src/property-presets/number/define.ts | 24 + .../src/property-presets/number/index.ts | 1 + .../src/property-presets/number/types.ts | 6 + .../property-presets/number/utils/formats.ts | 19 + .../number/utils/formatter.ts | 101 ++ .../progress/cell-renderer.ts | 223 ++++ .../src/property-presets/progress/define.ts | 20 + .../src/property-presets/pure-index.ts | 19 + .../property-presets/select/cell-renderer.ts | 98 ++ .../src/property-presets/select/define.ts | 66 + .../property-presets/text/cell-renderer.ts | 120 ++ .../src/property-presets/text/define.ts | 18 + .../src/view-presets/convert.ts | 21 + .../src/view-presets/index.ts | 11 + .../src/view-presets/kanban/card.ts | 333 +++++ .../src/view-presets/kanban/cell.ts | 191 +++ .../kanban/controller/clipboard.ts | 49 + .../view-presets/kanban/controller/drag.ts | 241 ++++ .../view-presets/kanban/controller/hotkeys.ts | 65 + .../kanban/controller/selection.ts | 750 +++++++++++ .../src/view-presets/kanban/define.ts | 92 ++ .../src/view-presets/kanban/group.ts | 210 +++ .../src/view-presets/kanban/header.ts | 71 + .../src/view-presets/kanban/index.ts | 4 + .../kanban/kanban-view-manager.ts | 316 +++++ .../src/view-presets/kanban/kanban-view.ts | 266 ++++ .../src/view-presets/kanban/menu.ts | 112 ++ .../src/view-presets/kanban/renderer.ts | 9 + .../src/view-presets/kanban/types.ts | 32 + .../src/view-presets/table/cell.ts | 185 +++ .../src/view-presets/table/components/menu.ts | 130 ++ .../src/view-presets/table/consts.ts | 10 + .../table/controller/clipboard.ts | 286 +++++ .../table/controller/drag-to-fill.ts | 111 ++ .../src/view-presets/table/controller/drag.ts | 210 +++ .../view-presets/table/controller/hotkeys.ts | 385 ++++++ .../table/controller/selection.ts | 1140 +++++++++++++++++ .../src/view-presets/table/define.ts | 51 + .../src/view-presets/table/group.ts | 235 ++++ .../table/header/column-header.ts | 139 ++ .../table/header/column-renderer.ts | 87 ++ .../table/header/microsheet-header-column.ts | 629 +++++++++ .../table/header/number-format-bar.ts | 145 +++ .../src/view-presets/table/header/styles.ts | 354 +++++ .../table/header/vertical-indicator.ts | 191 +++ .../src/view-presets/table/index.ts | 4 + .../src/view-presets/table/renderer.ts | 9 + .../table/row/row-select-checkbox.ts | 82 ++ .../src/view-presets/table/row/row.ts | 265 ++++ .../table/stats/column-stats-bar.ts | 56 + .../table/stats/column-stats-column.ts | 229 ++++ .../view-presets/table/table-view-manager.ts | 353 +++++ .../src/view-presets/table/table-view.ts | 316 +++++ .../src/view-presets/table/types.ts | 126 ++ .../src/widget-presets/filter/condition.ts | 250 ++++ .../src/widget-presets/filter/context.ts | 7 + .../src/widget-presets/filter/filter-bar.ts | 253 ++++ .../src/widget-presets/filter/filter-group.ts | 384 ++++++ .../src/widget-presets/filter/filter-modal.ts | 115 ++ .../src/widget-presets/filter/filter-root.ts | 318 +++++ .../src/widget-presets/filter/index.ts | 23 + .../widget-presets/filter/matcher/boolean.ts | 21 + .../src/widget-presets/filter/matcher/date.ts | 33 + .../widget-presets/filter/matcher/matcher.ts | 56 + .../filter/matcher/multi-tag.ts | 69 + .../widget-presets/filter/matcher/number.ts | 91 ++ .../widget-presets/filter/matcher/string.ts | 109 ++ .../src/widget-presets/filter/matcher/tag.ts | 40 + .../widget-presets/filter/matcher/unknown.ts | 33 + .../src/widget-presets/index.ts | 10 + .../src/widget-presets/tools/index.ts | 32 + .../tools/presets/filter/filter.ts | 116 ++ .../tools/presets/search/search.ts | 199 +++ .../tools/presets/table-add-row/add-row.ts | 214 ++++ .../table-add-row/new-record-preview.ts | 43 + .../presets/view-options/view-options.ts | 315 +++++ .../widget-presets/tools/tools-renderer.ts | 85 ++ .../src/widget-presets/views-bar/index.ts | 5 + .../src/widget-presets/views-bar/views.ts | 297 +++++ .../affine/microsheet-data-view/tsconfig.json | 26 + .../affine/microsheet-data-view/typedoc.json | 4 + .../microsheet-data-view/vitest.config.ts | 30 + .../model/src/blocks/cell/cell-model.ts | 31 + .../affine/model/src/blocks/cell/index.ts | 1 + packages/affine/model/src/blocks/index.ts | 2 + .../src/blocks/microsheet/microsheet-model.ts | 2 +- .../src/blocks/paragraph/paragraph-model.ts | 1 + packages/affine/model/src/blocks/row/index.ts | 1 + .../affine/model/src/blocks/row/row-model.ts | 21 + packages/blocks/package.json | 1 + packages/blocks/src/_specs/common.ts | 6 + packages/blocks/src/cell-block/cell-block.ts | 37 + .../blocks/src/cell-block/cell-service.ts | 35 + packages/blocks/src/cell-block/cell-spec.ts | 14 + packages/blocks/src/cell-block/index.ts | 16 + .../src/cell-block/keymap-controller.ts | 352 +++++ packages/blocks/src/cell-block/styles.ts | 13 + packages/blocks/src/effects.ts | 20 +- packages/blocks/src/index.ts | 3 + .../microsheet-block/context/host-context.ts | 2 +- .../src/microsheet-block/data-source.ts | 72 +- .../detail-panel/block-renderer.ts | 4 +- .../detail-panel/note-renderer.ts | 5 +- .../src/microsheet-block/microsheet-block.ts | 16 +- .../microsheet-block/microsheet-service.ts | 10 +- .../src/microsheet-block/microsheet-spec.ts | 2 +- .../microsheet-block/properties/converts.ts | 6 +- .../src/microsheet-block/properties/index.ts | 4 +- .../properties/link/cell-renderer.ts | 18 +- .../properties/link/define.ts | 2 +- .../properties/rich-text/cell-renderer.ts | 10 +- .../properties/rich-text/define.ts | 2 +- .../properties/title/cell-renderer.ts | 4 +- .../properties/title/define.ts | 2 +- .../microsheet-block/properties/title/icon.ts | 2 +- .../microsheet-block/properties/title/text.ts | 34 +- packages/blocks/src/microsheet-block/utils.ts | 8 +- .../src/microsheet-block/views/index.ts | 7 +- .../block-meta/base.ts | 36 + .../block-meta/index.ts | 7 + .../block-meta/todo.ts | 60 + .../columns/index.ts | 19 + .../microsheet-data-view-block/data-source.ts | 312 +++++ .../data-view-block.ts | 306 +++++ .../data-view-model.ts | 96 ++ .../data-view-spec.ts | 14 + .../database-service.ts | 13 + .../src/microsheet-data-view-block/index.ts | 12 + .../src/microsheet-data-view-block/utils.ts | 0 .../microsheet-data-view-block/views/index.ts | 12 + packages/blocks/src/row-block/index.ts | 16 + packages/blocks/src/row-block/row-block.ts | 58 + packages/blocks/src/row-block/row-service.ts | 10 + packages/blocks/src/row-block/row-spec.ts | 14 + packages/blocks/src/row-block/styles.ts | 3 + packages/blocks/src/schemas.ts | 6 + packages/playground/package.json | 1 + packages/playground/vite.config.ts | 1 + yarn.lock | 31 + 242 files changed, 23006 insertions(+), 115 deletions(-) create mode 100644 packages/affine/microsheet-data-view/CHANGELOG.md create mode 100644 packages/affine/microsheet-data-view/package.json create mode 100644 packages/affine/microsheet-data-view/src/core/common/ast.ts create mode 100644 packages/affine/microsheet-data-view/src/core/common/component/overflow/overflow.ts create mode 100644 packages/affine/microsheet-data-view/src/core/common/css-variable.ts create mode 100644 packages/affine/microsheet-data-view/src/core/common/data-source/base.ts create mode 100644 packages/affine/microsheet-data-view/src/core/common/data-source/context.ts create mode 100644 packages/affine/microsheet-data-view/src/core/common/data-source/index.ts create mode 100644 packages/affine/microsheet-data-view/src/core/common/detail/detail.ts create mode 100644 packages/affine/microsheet-data-view/src/core/common/detail/field.ts create mode 100644 packages/affine/microsheet-data-view/src/core/common/detail/selection.ts create mode 100644 packages/affine/microsheet-data-view/src/core/common/group-by.ts create mode 100644 packages/affine/microsheet-data-view/src/core/common/group-by/define.ts create mode 100644 packages/affine/microsheet-data-view/src/core/common/group-by/group-title.ts create mode 100644 packages/affine/microsheet-data-view/src/core/common/group-by/helper.ts create mode 100644 packages/affine/microsheet-data-view/src/core/common/group-by/matcher.ts create mode 100644 packages/affine/microsheet-data-view/src/core/common/group-by/renderer/base.ts create mode 100644 packages/affine/microsheet-data-view/src/core/common/group-by/renderer/boolean-group.ts create mode 100644 packages/affine/microsheet-data-view/src/core/common/group-by/renderer/number-group.ts create mode 100644 packages/affine/microsheet-data-view/src/core/common/group-by/renderer/select-group.ts create mode 100644 packages/affine/microsheet-data-view/src/core/common/group-by/renderer/string-group.ts create mode 100644 packages/affine/microsheet-data-view/src/core/common/group-by/setting.ts create mode 100644 packages/affine/microsheet-data-view/src/core/common/group-by/types.ts create mode 100644 packages/affine/microsheet-data-view/src/core/common/index.ts create mode 100644 packages/affine/microsheet-data-view/src/core/common/literal/define.ts create mode 100644 packages/affine/microsheet-data-view/src/core/common/literal/matcher.ts create mode 100644 packages/affine/microsheet-data-view/src/core/common/literal/renderer/array-literal.ts create mode 100644 packages/affine/microsheet-data-view/src/core/common/literal/renderer/date-literal.ts create mode 100644 packages/affine/microsheet-data-view/src/core/common/literal/renderer/literal-element.ts create mode 100644 packages/affine/microsheet-data-view/src/core/common/literal/renderer/tag-literal.ts create mode 100644 packages/affine/microsheet-data-view/src/core/common/literal/renderer/union-string.ts create mode 100644 packages/affine/microsheet-data-view/src/core/common/literal/types.ts create mode 100644 packages/affine/microsheet-data-view/src/core/common/popup.ts create mode 100644 packages/affine/microsheet-data-view/src/core/common/properties.ts create mode 100644 packages/affine/microsheet-data-view/src/core/common/property-menu.ts create mode 100644 packages/affine/microsheet-data-view/src/core/common/ref/ref.ts create mode 100644 packages/affine/microsheet-data-view/src/core/common/selection.ts create mode 100644 packages/affine/microsheet-data-view/src/core/common/stats/any.ts create mode 100644 packages/affine/microsheet-data-view/src/core/common/stats/checkbox.ts create mode 100644 packages/affine/microsheet-data-view/src/core/common/stats/index.ts create mode 100644 packages/affine/microsheet-data-view/src/core/common/stats/number.ts create mode 100644 packages/affine/microsheet-data-view/src/core/common/stats/type.ts create mode 100644 packages/affine/microsheet-data-view/src/core/common/types.ts create mode 100644 packages/affine/microsheet-data-view/src/core/data-view.ts create mode 100644 packages/affine/microsheet-data-view/src/core/index.ts create mode 100644 packages/affine/microsheet-data-view/src/core/logical/data-type.ts create mode 100644 packages/affine/microsheet-data-view/src/core/logical/eval-filter.ts create mode 100644 packages/affine/microsheet-data-view/src/core/logical/index.ts create mode 100644 packages/affine/microsheet-data-view/src/core/logical/matcher.ts create mode 100644 packages/affine/microsheet-data-view/src/core/logical/property-matcher.ts create mode 100644 packages/affine/microsheet-data-view/src/core/logical/typesystem.ts create mode 100644 packages/affine/microsheet-data-view/src/core/property/base-cell.ts create mode 100644 packages/affine/microsheet-data-view/src/core/property/convert.ts create mode 100644 packages/affine/microsheet-data-view/src/core/property/index.ts create mode 100644 packages/affine/microsheet-data-view/src/core/property/manager.ts create mode 100644 packages/affine/microsheet-data-view/src/core/property/property-config.ts create mode 100644 packages/affine/microsheet-data-view/src/core/property/renderer.ts create mode 100644 packages/affine/microsheet-data-view/src/core/property/types.ts create mode 100644 packages/affine/microsheet-data-view/src/core/types.ts create mode 100644 packages/affine/microsheet-data-view/src/core/utils/drag.ts create mode 100644 packages/affine/microsheet-data-view/src/core/utils/event.ts create mode 100644 packages/affine/microsheet-data-view/src/core/utils/frame-loop.ts create mode 100644 packages/affine/microsheet-data-view/src/core/utils/index.ts create mode 100644 packages/affine/microsheet-data-view/src/core/utils/menu-title.ts create mode 100644 packages/affine/microsheet-data-view/src/core/utils/tags/colors.ts create mode 100644 packages/affine/microsheet-data-view/src/core/utils/tags/index.ts create mode 100644 packages/affine/microsheet-data-view/src/core/utils/tags/multi-tag-select.ts create mode 100644 packages/affine/microsheet-data-view/src/core/utils/tags/multi-tag-view.ts create mode 100644 packages/affine/microsheet-data-view/src/core/utils/tags/styles.ts create mode 100644 packages/affine/microsheet-data-view/src/core/utils/uni-component/index.ts create mode 100644 packages/affine/microsheet-data-view/src/core/utils/uni-component/operation.ts create mode 100644 packages/affine/microsheet-data-view/src/core/utils/uni-component/render-template.ts create mode 100644 packages/affine/microsheet-data-view/src/core/utils/uni-component/uni-component.ts create mode 100644 packages/affine/microsheet-data-view/src/core/utils/uni-icon.ts create mode 100644 packages/affine/microsheet-data-view/src/core/utils/utils.ts create mode 100644 packages/affine/microsheet-data-view/src/core/view-manager/cell.ts create mode 100644 packages/affine/microsheet-data-view/src/core/view-manager/index.ts create mode 100644 packages/affine/microsheet-data-view/src/core/view-manager/property.ts create mode 100644 packages/affine/microsheet-data-view/src/core/view-manager/row.ts create mode 100644 packages/affine/microsheet-data-view/src/core/view-manager/single-view.ts create mode 100644 packages/affine/microsheet-data-view/src/core/view-manager/view-manager.ts create mode 100644 packages/affine/microsheet-data-view/src/core/view/convert.ts create mode 100644 packages/affine/microsheet-data-view/src/core/view/data-view-base.ts create mode 100644 packages/affine/microsheet-data-view/src/core/view/data-view.ts create mode 100644 packages/affine/microsheet-data-view/src/core/view/index.ts create mode 100644 packages/affine/microsheet-data-view/src/core/view/types.ts create mode 100644 packages/affine/microsheet-data-view/src/core/widget/index.ts create mode 100644 packages/affine/microsheet-data-view/src/core/widget/types.ts create mode 100644 packages/affine/microsheet-data-view/src/core/widget/widget-base.ts create mode 100644 packages/affine/microsheet-data-view/src/effects.ts create mode 100644 packages/affine/microsheet-data-view/src/index.ts create mode 100644 packages/affine/microsheet-data-view/src/property-presets/checkbox/cell-renderer.ts create mode 100644 packages/affine/microsheet-data-view/src/property-presets/checkbox/define.ts create mode 100644 packages/affine/microsheet-data-view/src/property-presets/converts.ts create mode 100644 packages/affine/microsheet-data-view/src/property-presets/date/cell-renderer.ts create mode 100644 packages/affine/microsheet-data-view/src/property-presets/date/define.ts create mode 100644 packages/affine/microsheet-data-view/src/property-presets/image/cell-renderer.ts create mode 100644 packages/affine/microsheet-data-view/src/property-presets/image/define.ts create mode 100644 packages/affine/microsheet-data-view/src/property-presets/index.ts create mode 100644 packages/affine/microsheet-data-view/src/property-presets/multi-select/cell-renderer.ts create mode 100644 packages/affine/microsheet-data-view/src/property-presets/multi-select/define.ts create mode 100644 packages/affine/microsheet-data-view/src/property-presets/number/cell-renderer.ts create mode 100644 packages/affine/microsheet-data-view/src/property-presets/number/define.ts create mode 100644 packages/affine/microsheet-data-view/src/property-presets/number/index.ts create mode 100644 packages/affine/microsheet-data-view/src/property-presets/number/types.ts create mode 100644 packages/affine/microsheet-data-view/src/property-presets/number/utils/formats.ts create mode 100644 packages/affine/microsheet-data-view/src/property-presets/number/utils/formatter.ts create mode 100644 packages/affine/microsheet-data-view/src/property-presets/progress/cell-renderer.ts create mode 100644 packages/affine/microsheet-data-view/src/property-presets/progress/define.ts create mode 100644 packages/affine/microsheet-data-view/src/property-presets/pure-index.ts create mode 100644 packages/affine/microsheet-data-view/src/property-presets/select/cell-renderer.ts create mode 100644 packages/affine/microsheet-data-view/src/property-presets/select/define.ts create mode 100644 packages/affine/microsheet-data-view/src/property-presets/text/cell-renderer.ts create mode 100644 packages/affine/microsheet-data-view/src/property-presets/text/define.ts create mode 100644 packages/affine/microsheet-data-view/src/view-presets/convert.ts create mode 100644 packages/affine/microsheet-data-view/src/view-presets/index.ts create mode 100644 packages/affine/microsheet-data-view/src/view-presets/kanban/card.ts create mode 100644 packages/affine/microsheet-data-view/src/view-presets/kanban/cell.ts create mode 100644 packages/affine/microsheet-data-view/src/view-presets/kanban/controller/clipboard.ts create mode 100644 packages/affine/microsheet-data-view/src/view-presets/kanban/controller/drag.ts create mode 100644 packages/affine/microsheet-data-view/src/view-presets/kanban/controller/hotkeys.ts create mode 100644 packages/affine/microsheet-data-view/src/view-presets/kanban/controller/selection.ts create mode 100644 packages/affine/microsheet-data-view/src/view-presets/kanban/define.ts create mode 100644 packages/affine/microsheet-data-view/src/view-presets/kanban/group.ts create mode 100644 packages/affine/microsheet-data-view/src/view-presets/kanban/header.ts create mode 100644 packages/affine/microsheet-data-view/src/view-presets/kanban/index.ts create mode 100644 packages/affine/microsheet-data-view/src/view-presets/kanban/kanban-view-manager.ts create mode 100644 packages/affine/microsheet-data-view/src/view-presets/kanban/kanban-view.ts create mode 100644 packages/affine/microsheet-data-view/src/view-presets/kanban/menu.ts create mode 100644 packages/affine/microsheet-data-view/src/view-presets/kanban/renderer.ts create mode 100644 packages/affine/microsheet-data-view/src/view-presets/kanban/types.ts create mode 100644 packages/affine/microsheet-data-view/src/view-presets/table/cell.ts create mode 100644 packages/affine/microsheet-data-view/src/view-presets/table/components/menu.ts create mode 100644 packages/affine/microsheet-data-view/src/view-presets/table/consts.ts create mode 100644 packages/affine/microsheet-data-view/src/view-presets/table/controller/clipboard.ts create mode 100644 packages/affine/microsheet-data-view/src/view-presets/table/controller/drag-to-fill.ts create mode 100644 packages/affine/microsheet-data-view/src/view-presets/table/controller/drag.ts create mode 100644 packages/affine/microsheet-data-view/src/view-presets/table/controller/hotkeys.ts create mode 100644 packages/affine/microsheet-data-view/src/view-presets/table/controller/selection.ts create mode 100644 packages/affine/microsheet-data-view/src/view-presets/table/define.ts create mode 100644 packages/affine/microsheet-data-view/src/view-presets/table/group.ts create mode 100644 packages/affine/microsheet-data-view/src/view-presets/table/header/column-header.ts create mode 100644 packages/affine/microsheet-data-view/src/view-presets/table/header/column-renderer.ts create mode 100644 packages/affine/microsheet-data-view/src/view-presets/table/header/microsheet-header-column.ts create mode 100644 packages/affine/microsheet-data-view/src/view-presets/table/header/number-format-bar.ts create mode 100644 packages/affine/microsheet-data-view/src/view-presets/table/header/styles.ts create mode 100644 packages/affine/microsheet-data-view/src/view-presets/table/header/vertical-indicator.ts create mode 100644 packages/affine/microsheet-data-view/src/view-presets/table/index.ts create mode 100644 packages/affine/microsheet-data-view/src/view-presets/table/renderer.ts create mode 100644 packages/affine/microsheet-data-view/src/view-presets/table/row/row-select-checkbox.ts create mode 100644 packages/affine/microsheet-data-view/src/view-presets/table/row/row.ts create mode 100644 packages/affine/microsheet-data-view/src/view-presets/table/stats/column-stats-bar.ts create mode 100644 packages/affine/microsheet-data-view/src/view-presets/table/stats/column-stats-column.ts create mode 100644 packages/affine/microsheet-data-view/src/view-presets/table/table-view-manager.ts create mode 100644 packages/affine/microsheet-data-view/src/view-presets/table/table-view.ts create mode 100644 packages/affine/microsheet-data-view/src/view-presets/table/types.ts create mode 100644 packages/affine/microsheet-data-view/src/widget-presets/filter/condition.ts create mode 100644 packages/affine/microsheet-data-view/src/widget-presets/filter/context.ts create mode 100644 packages/affine/microsheet-data-view/src/widget-presets/filter/filter-bar.ts create mode 100644 packages/affine/microsheet-data-view/src/widget-presets/filter/filter-group.ts create mode 100644 packages/affine/microsheet-data-view/src/widget-presets/filter/filter-modal.ts create mode 100644 packages/affine/microsheet-data-view/src/widget-presets/filter/filter-root.ts create mode 100644 packages/affine/microsheet-data-view/src/widget-presets/filter/index.ts create mode 100644 packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/boolean.ts create mode 100644 packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/date.ts create mode 100644 packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/matcher.ts create mode 100644 packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/multi-tag.ts create mode 100644 packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/number.ts create mode 100644 packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/string.ts create mode 100644 packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/tag.ts create mode 100644 packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/unknown.ts create mode 100644 packages/affine/microsheet-data-view/src/widget-presets/index.ts create mode 100644 packages/affine/microsheet-data-view/src/widget-presets/tools/index.ts create mode 100644 packages/affine/microsheet-data-view/src/widget-presets/tools/presets/filter/filter.ts create mode 100644 packages/affine/microsheet-data-view/src/widget-presets/tools/presets/search/search.ts create mode 100644 packages/affine/microsheet-data-view/src/widget-presets/tools/presets/table-add-row/add-row.ts create mode 100644 packages/affine/microsheet-data-view/src/widget-presets/tools/presets/table-add-row/new-record-preview.ts create mode 100644 packages/affine/microsheet-data-view/src/widget-presets/tools/presets/view-options/view-options.ts create mode 100644 packages/affine/microsheet-data-view/src/widget-presets/tools/tools-renderer.ts create mode 100644 packages/affine/microsheet-data-view/src/widget-presets/views-bar/index.ts create mode 100644 packages/affine/microsheet-data-view/src/widget-presets/views-bar/views.ts create mode 100644 packages/affine/microsheet-data-view/tsconfig.json create mode 100644 packages/affine/microsheet-data-view/typedoc.json create mode 100644 packages/affine/microsheet-data-view/vitest.config.ts create mode 100644 packages/affine/model/src/blocks/cell/cell-model.ts create mode 100644 packages/affine/model/src/blocks/cell/index.ts create mode 100644 packages/affine/model/src/blocks/row/index.ts create mode 100644 packages/affine/model/src/blocks/row/row-model.ts create mode 100644 packages/blocks/src/cell-block/cell-block.ts create mode 100644 packages/blocks/src/cell-block/cell-service.ts create mode 100644 packages/blocks/src/cell-block/cell-spec.ts create mode 100644 packages/blocks/src/cell-block/index.ts create mode 100644 packages/blocks/src/cell-block/keymap-controller.ts create mode 100644 packages/blocks/src/cell-block/styles.ts create mode 100644 packages/blocks/src/microsheet-data-view-block/block-meta/base.ts create mode 100644 packages/blocks/src/microsheet-data-view-block/block-meta/index.ts create mode 100644 packages/blocks/src/microsheet-data-view-block/block-meta/todo.ts create mode 100644 packages/blocks/src/microsheet-data-view-block/columns/index.ts create mode 100644 packages/blocks/src/microsheet-data-view-block/data-source.ts create mode 100644 packages/blocks/src/microsheet-data-view-block/data-view-block.ts create mode 100644 packages/blocks/src/microsheet-data-view-block/data-view-model.ts create mode 100644 packages/blocks/src/microsheet-data-view-block/data-view-spec.ts create mode 100644 packages/blocks/src/microsheet-data-view-block/database-service.ts create mode 100644 packages/blocks/src/microsheet-data-view-block/index.ts create mode 100644 packages/blocks/src/microsheet-data-view-block/utils.ts create mode 100644 packages/blocks/src/microsheet-data-view-block/views/index.ts create mode 100644 packages/blocks/src/row-block/index.ts create mode 100644 packages/blocks/src/row-block/row-block.ts create mode 100644 packages/blocks/src/row-block/row-service.ts create mode 100644 packages/blocks/src/row-block/row-spec.ts create mode 100644 packages/blocks/src/row-block/styles.ts diff --git a/packages/affine/data-view/src/core/common/selection-schema.ts b/packages/affine/data-view/src/core/common/selection-schema.ts index 23cd19dcf3ac..eff1c5ebd2ef 100644 --- a/packages/affine/data-view/src/core/common/selection-schema.ts +++ b/packages/affine/data-view/src/core/common/selection-schema.ts @@ -188,5 +188,3 @@ declare global { } export const DatabaseSelectionExtension = SelectionExtension(DatabaseSelection); -export const MicrosheetSelectionExtension = - SelectionExtension(MicrosheetSelection); diff --git a/packages/affine/microsheet-data-view/CHANGELOG.md b/packages/affine/microsheet-data-view/CHANGELOG.md new file mode 100644 index 000000000000..f0254fcccfdc --- /dev/null +++ b/packages/affine/microsheet-data-view/CHANGELOG.md @@ -0,0 +1,417 @@ +# @blocksuite/data-view + +## 0.17.19 + +### Patch Changes + +- b69b00e: --- + + '@blocksuite/affine-block-list': patch + '@blocksuite/affine-block-paragraph': patch + '@blocksuite/affine-block-surface': patch + '@blocksuite/affine-components': patch + '@blocksuite/data-view': patch + '@blocksuite/affine-model': patch + '@blocksuite/affine-shared': patch + '@blocksuite/blocks': patch + '@blocksuite/docs': patch + '@blocksuite/block-std': patch + '@blocksuite/global': patch + '@blocksuite/inline': patch + '@blocksuite/store': patch + '@blocksuite/sync': patch + '@blocksuite/presets': patch + + *** + + [feat: markdown adapter with latex](https://github.com/toeverything/blocksuite/pull/8503) + + [feat: support notion block equation html import](https://github.com/toeverything/blocksuite/pull/8504) + + [feat: support edgeless tidy up](https://github.com/toeverything/blocksuite/pull/8516) + + [feat: support notion callout block to blocksuite quote block](https://github.com/toeverything/blocksuite/pull/8523) + + [feat(playground): add import notion zip entry](https://github.com/toeverything/blocksuite/pull/8527) + + [fix(blocks): auto focus latex block](https://github.com/toeverything/blocksuite/pull/8505) + + [fix: enhance button layout with icon alignment](https://github.com/toeverything/blocksuite/pull/8508) + + [fix(edgeless): ime will crash edgeless text width](https://github.com/toeverything/blocksuite/pull/8506) + + [fix(edgeless): edgeless text is deleted when first block is empty](https://github.com/toeverything/blocksuite/pull/8512) + + [fix: notion html quote block import](https://github.com/toeverything/blocksuite/pull/8515) + + [fix: yjs warning](https://github.com/toeverything/blocksuite/pull/8519) + + [fix(blocks): real nested list on html export](https://github.com/toeverything/blocksuite/pull/8511) + + [fix(edgeless): cmd a will select element inner frame](https://github.com/toeverything/blocksuite/pull/8517) + + [fix(edgeless): disable contenteditable when edgeless text not in editing state](https://github.com/toeverything/blocksuite/pull/8525) + + [fix: import notion toggle list as toggle bulleted list](https://github.com/toeverything/blocksuite/pull/8528) + + [refactor(microsheet): signals version datasource api](https://github.com/toeverything/blocksuite/pull/8513) + + [refactor(edgeless): element tree manager](https://github.com/toeverything/blocksuite/pull/8239) + + [refactor(blocks): simplify frame manager implementation](https://github.com/toeverything/blocksuite/pull/8507) + + [refactor: update group test utils using container interface](https://github.com/toeverything/blocksuite/pull/8518) + + [refactor: update frame test with container test uitls](https://github.com/toeverything/blocksuite/pull/8520) + + [refactor(microsheet): context-menu ui and ux](https://github.com/toeverything/blocksuite/pull/8467) + + [refactor: move chat block to affine](https://github.com/toeverything/blocksuite/pull/8420) + + [perf: optimize snapshot job handling](https://github.com/toeverything/blocksuite/pull/8428) + + [perf(edgeless): disable shape shadow blur](https://github.com/toeverything/blocksuite/pull/8532) + + [chore: bump up all non-major dependencies](https://github.com/toeverything/blocksuite/pull/8514) + + [chore: Lock file maintenance](https://github.com/toeverything/blocksuite/pull/8510) + + [docs: fix table structure warning](https://github.com/toeverything/blocksuite/pull/8509) + + [docs: edgeless data structure desc](https://github.com/toeverything/blocksuite/pull/8531) + + [docs: update link](https://github.com/toeverything/blocksuite/pull/8533) + +- Updated dependencies [b69b00e] + - @blocksuite/affine-components@0.17.19 + - @blocksuite/affine-shared@0.17.19 + - @blocksuite/block-std@0.17.19 + - @blocksuite/global@0.17.19 + - @blocksuite/store@0.17.19 + +## 0.17.18 + +### Patch Changes + +- 9f70715: Bug Fixes: + + - fix(blocks): can not search in at menu with IME. [#8481](https://github.com/toeverything/blocksuite/pull/8481) + - fix(std): dispatcher pointerUp calls twice. [#8485](https://github.com/toeverything/blocksuite/pull/8485) + - fix(blocks): pasting elements with css inline style. [#8491](https://github.com/toeverything/blocksuite/pull/8491) + - fix(blocks): hide outline panel toggle button when callback is null. [#8493](https://github.com/toeverything/blocksuite/pull/8493) + - fix(blocks): pasting twice when span inside h tag. [#8496](https://github.com/toeverything/blocksuite/pull/8496) + - fix(blocks): image should be displayed when in vertical mode. [#8497](https://github.com/toeverything/blocksuite/pull/8497) + - fix: press backspace at the start of first line when edgeless text exist. [#8498](https://github.com/toeverything/blocksuite/pull/8498) + +- Updated dependencies [9f70715] + - @blocksuite/affine-components@0.17.18 + - @blocksuite/affine-shared@0.17.18 + - @blocksuite/block-std@0.17.18 + - @blocksuite/global@0.17.18 + - @blocksuite/store@0.17.18 + +## 0.17.17 + +### Patch Changes + +- a89c9c1: ## Features + + - feat: selection extension [#8464](https://github.com/toeverything/blocksuite/pull/8464) + + ## Bug Fixes + + - perf(edgeless): reduce refresh of frame overlay [#8476](https://github.com/toeverything/blocksuite/pull/8476) + - fix(blocks): improve edgeless text block resizing behavior [#8473](https://github.com/toeverything/blocksuite/pull/8473) + - fix: turn off smooth scaling and cache bounds [#8472](https://github.com/toeverything/blocksuite/pull/8472) + - fix: add strategy option for portal [#8470](https://github.com/toeverything/blocksuite/pull/8470) + - fix(blocks): fix slash menu is triggered in ignored blocks [#8469](https://github.com/toeverything/blocksuite/pull/8469) + - fix(blocks): incorrect width of embed-linked-doc-block in edgeless [#8463](https://github.com/toeverything/blocksuite/pull/8463) + - fix: improve open link on link popup [#8462](https://github.com/toeverything/blocksuite/pull/8462) + - fix: do not enable shift-click center peek in edgeless [#8460](https://github.com/toeverything/blocksuite/pull/8460) + - fix(microsheet): disable microsheet block full-width in edgeless mode [#8461](https://github.com/toeverything/blocksuite/pull/8461) + - fix: check editable element active more accurately [#8457](https://github.com/toeverything/blocksuite/pull/8457) + - fix: edgeless image block rotate [#8458](https://github.com/toeverything/blocksuite/pull/8458) + - fix: outline popup ref area [#8456](https://github.com/toeverything/blocksuite/pull/8456) + +- Updated dependencies [a89c9c1] + - @blocksuite/affine-components@0.17.17 + - @blocksuite/affine-shared@0.17.17 + - @blocksuite/block-std@0.17.17 + - @blocksuite/global@0.17.17 + - @blocksuite/store@0.17.17 + +## 0.17.16 + +### Patch Changes + +- ce9a242: Fix bugs and improve experience: + + - fix slash menu and @ menu issues with IME [#8444](https://github.com/toeverything/blocksuite/pull/8444) + - improve trigger way of latex editor [#8445](https://github.com/toeverything/blocksuite/pull/8445) + - support in-app link jump [#8499](https://github.com/toeverything/blocksuite/pull/8449) + - some ui improvements [#8446](https://github.com/toeverything/blocksuite/pull/8446), [#8450](https://github.com/toeverything/blocksuite/pull/8450) + +- Updated dependencies [ce9a242] + - @blocksuite/affine-components@0.17.16 + - @blocksuite/affine-shared@0.17.16 + - @blocksuite/block-std@0.17.16 + - @blocksuite/global@0.17.16 + - @blocksuite/store@0.17.16 + +## 0.17.15 + +### Patch Changes + +- 931315f: - Fix: Improved scroll behavior to target elements + - Fix: Enhanced bookmark and synced document block styles + - Fix: Resolved issues with PDF printing completion + - Fix: Prevented LaTeX editor from triggering at the start of a line + - Fix: Adjusted portal position in blocks + - Fix: Improved mindmap layout for existing models + - Feature: Added file type detection for exports + - Feature: Enhanced block visibility UI in Edgeless mode + - Refactor: Improved data source API for microsheet + - Refactor: Ensured new block elements are always on top in Edgeless mode + - Chore: Upgraded non-major dependencies + - Chore: Improved ThemeObserver and added tests +- Updated dependencies [931315f] + - @blocksuite/affine-components@0.17.15 + - @blocksuite/affine-shared@0.17.15 + - @blocksuite/block-std@0.17.15 + - @blocksuite/global@0.17.15 + - @blocksuite/store@0.17.15 + +## 0.17.14 + +### Patch Changes + +- 163cb11: - Provide an all-in-one package for Affine. + - Fix duplication occurs when card view is switched to embed view. + - Improve linked block status detection. + - Separate user extensions and internal extensions in std. + - Fix add note feature in microsheet. + - Fix pasting multiple times when span nested in p. + - Refactor range sync. +- Updated dependencies [163cb11] + - @blocksuite/affine-components@0.17.14 + - @blocksuite/affine-shared@0.17.14 + - @blocksuite/block-std@0.17.14 + - @blocksuite/global@0.17.14 + - @blocksuite/store@0.17.14 + +## 0.17.13 + +### Patch Changes + +- 9de68e3: Update mindmap uitls export +- Updated dependencies [9de68e3] + - @blocksuite/affine-components@0.17.13 + - @blocksuite/affine-shared@0.17.13 + - @blocksuite/block-std@0.17.13 + - @blocksuite/global@0.17.13 + - @blocksuite/store@0.17.13 + +## 0.17.12 + +### Patch Changes + +- c334c91: - fix(microsheet): remove image column + - fix: frame preview should update correctly after mode switched + - refactor: move with-disposable and signal-watcher to global package + - fix(edgeless): failed to alt clone move frame when it contains container element + - fix: wrong size limit config +- Updated dependencies [c334c91] + - @blocksuite/affine-components@0.17.12 + - @blocksuite/affine-shared@0.17.12 + - @blocksuite/block-std@0.17.12 + - @blocksuite/global@0.17.12 + - @blocksuite/store@0.17.12 + +## 0.17.11 + +### Patch Changes + +- 1052ebd: - Refactor drag handle widget + - Split embed blocks to `@blocksuite/affine-block-embed` + - Fix latex selected state in edgeless mode + - Fix unclear naming + - Fix prototype pollution + - Fix portal interaction in affine modal + - Fix paste linked block on edgeless + - Add scroll anchoring widget + - Add highlight selection +- Updated dependencies [1052ebd] + - @blocksuite/affine-components@0.17.11 + - @blocksuite/affine-shared@0.17.11 + - @blocksuite/block-std@0.17.11 + - @blocksuite/global@0.17.11 + - @blocksuite/store@0.17.11 + +## 0.17.10 + +### Patch Changes + +- e0d0016: - Fix microsheet performance issue + - Fix frame panel display issue + - Fix editor settings for color with transparency + - Fix portal in modals + - Fix group selection rendering delay + - Remove unused and duplicated code + - Improve frame model + - Improve ParseDocUrl service + - Support custom max zoom +- Updated dependencies [e0d0016] + - @blocksuite/affine-components@0.17.10 + - @blocksuite/affine-shared@0.17.10 + - @blocksuite/block-std@0.17.10 + - @blocksuite/global@0.17.10 + - @blocksuite/store@0.17.10 + +## 0.17.9 + +### Patch Changes + +- 5f29800: - Fix latex issues + - Fix inline embed gap + - Fix edgeless text color + - Fix outline panel note status + - Improve mindmap + - Add sideEffects: false to all packages + - Add parse url service + - Add ref node slots extension +- Updated dependencies [5f29800] + - @blocksuite/affine-components@0.17.9 + - @blocksuite/affine-shared@0.17.9 + - @blocksuite/block-std@0.17.9 + - @blocksuite/global@0.17.9 + - @blocksuite/store@0.17.9 + +## 0.17.8 + +### Patch Changes + +- 2f7dbe9: - feat(microsheet): easy access to property visibility + - fix: mind map issues + - feat(microsheet): supports switching view types + - fix(blocks): should use cardStyle for rendering + - test: add mini-mindmap test + - feat(microsheet): full width POC +- Updated dependencies [2f7dbe9] + - @blocksuite/affine-components@0.17.8 + - @blocksuite/affine-shared@0.17.8 + - @blocksuite/block-std@0.17.8 + - @blocksuite/global@0.17.8 + - @blocksuite/store@0.17.8 + +## 0.17.7 + +### Patch Changes + +- 5ab06c3: - Peek view as extension + - Editor settings as extension + - Edit props store as extension + - Notifications as extension + - Fix mini mindmap get service error + - Fix generating placeholder style + - Fix brush menu settings + - Fix brush element line width + - Fix edgeless preview pointer events + - Fix latex editor focus shake +- Updated dependencies [5ab06c3] + - @blocksuite/affine-components@0.17.7 + - @blocksuite/affine-shared@0.17.7 + - @blocksuite/block-std@0.17.7 + - @blocksuite/global@0.17.7 + - @blocksuite/store@0.17.7 + +## 0.17.6 + +### Patch Changes + +- d8d5656: - Fix latex block export + - Fix rich text reference config export + - Fix mindmap export dependency error + - Fix toast position + - Fix frame remember settings + - Microsheet statistic improvements + - Add keymap extension +- Updated dependencies [d8d5656] + - @blocksuite/affine-components@0.17.6 + - @blocksuite/affine-shared@0.17.6 + - @blocksuite/block-std@0.17.6 + - @blocksuite/global@0.17.6 + - @blocksuite/store@0.17.6 + +## 0.17.5 + +### Patch Changes + +- debf65c: - Fix latex export + - Fix add group in microsheet kanban view + - Fix presentation mode `Esc` key + - Fix url parse and paste for block reference + - Frame improvement + - Microsheet checkbox statistics improvement + - Inline extensions + - Mindmap remember last settings +- Updated dependencies [debf65c] + - @blocksuite/affine-components@0.17.5 + - @blocksuite/affine-shared@0.17.5 + - @blocksuite/block-std@0.17.5 + - @blocksuite/global@0.17.5 + - @blocksuite/store@0.17.5 + +## 0.17.4 + +### Patch Changes + +- 9978a71: Create git tag +- Updated dependencies [9978a71] + - @blocksuite/affine-components@0.17.4 + - @blocksuite/affine-shared@0.17.4 + - @blocksuite/block-std@0.17.4 + - @blocksuite/global@0.17.4 + - @blocksuite/store@0.17.4 + +## 0.17.3 + +### Patch Changes + +- be60caf: Generate git tag +- Updated dependencies [be60caf] + - @blocksuite/affine-components@0.17.3 + - @blocksuite/affine-shared@0.17.3 + - @blocksuite/block-std@0.17.3 + - @blocksuite/global@0.17.3 + - @blocksuite/store@0.17.3 + +## 0.17.2 + +### Patch Changes + +- 5543e32: Fix missing export in dataview +- Updated dependencies [5543e32] + - @blocksuite/affine-components@0.17.2 + - @blocksuite/affine-shared@0.17.2 + - @blocksuite/block-std@0.17.2 + - @blocksuite/global@0.17.2 + - @blocksuite/store@0.17.2 + +## 0.17.1 + +### Patch Changes + +- 21b5d47: BlockSuite 0.17.1 + + Add @blocksuite/data-view package. + Make font loader an extension. + Frame improvement. + Fix missing xywh when copy/paste mind map. + Fix connector label text. + +- Updated dependencies [21b5d47] + - @blocksuite/affine-components@0.17.1 + - @blocksuite/affine-shared@0.17.1 + - @blocksuite/block-std@0.17.1 + - @blocksuite/global@0.17.1 + - @blocksuite/store@0.17.1 diff --git a/packages/affine/microsheet-data-view/package.json b/packages/affine/microsheet-data-view/package.json new file mode 100644 index 000000000000..8eb9b074f4f7 --- /dev/null +++ b/packages/affine/microsheet-data-view/package.json @@ -0,0 +1,85 @@ +{ + "name": "@blocksuite/microsheet-data-view", + "version": "0.17.19", + "description": "Views of microsheet in affine", + "type": "module", + "repository": { + "type": "git", + "url": "https://github.com/toeverything/blocksuite.git", + "directory": "packages/affine/microsheet-data-view" + }, + "scripts": { + "build": "tsc", + "test:unit": "nx vite:test --run --passWithNoTests", + "test:unit:coverage": "nx vite:test --run --coverage", + "test:e2e": "playwright test" + }, + "sideEffects": false, + "keywords": [], + "author": "toeverything", + "license": "MPL-2.0", + "dependencies": { + "@blocksuite/affine-components": "workspace:*", + "@blocksuite/affine-shared": "workspace:*", + "@blocksuite/block-std": "workspace:*", + "@blocksuite/global": "workspace:*", + "@blocksuite/icons": "^2.1.68", + "@blocksuite/store": "workspace:*", + "@floating-ui/dom": "^1.6.10", + "@lit/context": "^1.1.2", + "@preact/signals-core": "^1.8.0", + "@toeverything/theme": "^1.0.8", + "date-fns": "^4.0.0", + "lit": "^3.2.0", + "sortablejs": "^1.15.2", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/sortablejs": "^1.15.8" + }, + "exports": { + ".": "./src/index.ts", + "./property-presets": "./src/property-presets/index.ts", + "./property-pure-presets": "./src/property-presets/pure-index.ts", + "./view-presets": "./src/view-presets/index.ts", + "./widget-presets": "./src/widget-presets/index.ts", + "./effects": "./src/effects.ts" + }, + "publishConfig": { + "access": "public", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./property-presets": { + "import": "./dist/property-presets/index.js", + "types": "./dist/property-presets/index.d.ts" + }, + "./property-pure-presets": { + "import": "./dist/property-presets/pure-index.js", + "types": "./dist/property-presets/pure-index.d.ts" + }, + "./view-presets": { + "import": "./dist/view-presets/index.js", + "types": "./dist/view-presets/index.d.ts" + }, + "./widget-presets": { + "import": "./dist/widget-presets/index.js", + "types": "./dist/widget-presets/index.d.ts" + }, + "./effects": { + "import": "./dist/effects.js", + "types": "./dist/effects.d.ts" + } + } + }, + "files": [ + "src", + "dist", + "!src/__tests__", + "!dist/__tests__" + ] +} diff --git a/packages/affine/microsheet-data-view/src/core/common/ast.ts b/packages/affine/microsheet-data-view/src/core/common/ast.ts new file mode 100644 index 000000000000..ce01547b0339 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/ast.ts @@ -0,0 +1,119 @@ +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; + +import type { TType } from '../logical/typesystem.js'; +import type { UniComponent } from '../utils/uni-component/uni-component.js'; + +import { filterMatcher } from '../../widget-presets/filter/matcher/matcher.js'; +import { propertyMatcher } from '../logical/property-matcher.js'; + +export type Variable = { + name: string; + type: TType; + id: string; + icon?: UniComponent; +}; +export type FilterGroup = { + type: 'group'; + op: 'and' | 'or'; + conditions: Filter[]; +}; +export type VariableRef = { + type: 'ref'; + name: string; +}; + +export type Property = { + type: 'property'; + ref: VariableRef; + propertyFuncName: string; +}; + +export type VariableOrProperty = VariableRef | Property; + +export type Literal = { + type: 'literal'; + value: unknown; +}; +export type Value = /*VariableRef*/ Literal; +export type SingleFilter = { + type: 'filter'; + left: VariableOrProperty; + function?: string; + args: Value[]; +}; +export type Filter = SingleFilter | FilterGroup; +export type SortExp = { + left: VariableOrProperty; + type: 'asc' | 'desc'; +}; + +export type SortGroup = SortExp[]; + +export type GroupExp = { + left: VariableOrProperty; + type: 'asc' | 'desc'; +}; + +export type GroupList = GroupExp[]; +export const getRefType = (vars: Variable[], ref: VariableOrProperty) => { + if (ref.type === 'ref') { + return vars.find(v => v.id === ref.name)?.type; + } + return propertyMatcher.find(v => v.data.name === ref.propertyFuncName)?.type + .rt; +}; +export const firstFilterName = (vars: Variable[], ref: VariableOrProperty) => { + const type = getRefType(vars, ref); + if (!type) { + throw new BlockSuiteError( + ErrorCode.MicrosheetBlockError, + `can't resolve ref type` + ); + } + return filterMatcher.match(type)?.name; +}; + +export const firstFilterByRef = ( + vars: Variable[], + ref: VariableOrProperty +): SingleFilter => { + return { + type: 'filter', + left: ref, + function: firstFilterName(vars, ref), + args: [], + }; +}; + +export const firstFilter = (vars: Variable[]): SingleFilter => { + const ref: VariableRef = { + type: 'ref', + name: vars[0].id, + }; + const filter = firstFilterName(vars, ref); + if (!filter) { + throw new BlockSuiteError( + ErrorCode.MicrosheetBlockError, + `can't match any filter` + ); + } + return { + type: 'filter', + left: ref, + function: filter, + args: [], + }; +}; + +export const firstFilterInGroup = (vars: Variable[]): FilterGroup => { + return { + type: 'group', + op: 'and', + conditions: [firstFilter(vars)], + }; +}; +export const emptyFilterGroup: FilterGroup = { + type: 'group', + op: 'and', + conditions: [], +}; diff --git a/packages/affine/microsheet-data-view/src/core/common/component/overflow/overflow.ts b/packages/affine/microsheet-data-view/src/core/common/component/overflow/overflow.ts new file mode 100644 index 000000000000..8423ddee4f8e --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/component/overflow/overflow.ts @@ -0,0 +1,107 @@ +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { css, html, type PropertyValues, type TemplateResult } from 'lit'; +import { property, query, queryAll, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { repeat } from 'lit/directives/repeat.js'; + +export class Overflow extends SignalWatcher(WithDisposable(ShadowlessElement)) { + static override styles = css` + microsheet-component-overflow { + display: flex; + flex-wrap: wrap; + width: 100%; + position: relative; + } + + .microsheet-component-overflow-item { + } + .microsheet-component-overflow-item.hidden { + opacity: 0; + pointer-events: none; + position: absolute; + } + `; + + protected frameId: number | undefined = undefined; + + protected widthList: number[] = []; + + adjustStyle() { + if (this.frameId) { + cancelAnimationFrame(this.frameId); + } + + this.frameId = requestAnimationFrame(() => { + this.doAdjustStyle(); + }); + } + + override connectedCallback() { + super.connectedCallback(); + const resize = new ResizeObserver(() => { + this.adjustStyle(); + }); + resize.observe(this); + this.disposables.add(() => { + resize.unobserve(this); + }); + } + + protected doAdjustStyle() { + const moreWidth = this.more.getBoundingClientRect().width; + this.widthList[this.renderCount] = moreWidth; + + const containerWidth = this.getBoundingClientRect().width; + + let width = 0; + for (let i = 0; i < this.items.length; i++) { + const itemWidth = this.items[i].getBoundingClientRect().width; + // Try to calculate the width occupied by rendering n+1 items; + // if it exceeds the limit, render n items(in i++ round). + const totalWidth = + width + itemWidth + (this.widthList[i + 1] ?? moreWidth); + if (totalWidth > containerWidth) { + this.renderCount = i; + return; + } + width += itemWidth; + } + this.renderCount = this.items.length; + } + + override render() { + return html` + ${repeat(this.renderItem, (render, index) => { + const className = classMap({ + 'microsheet-component-overflow-item': true, + hidden: index >= this.renderCount, + }); + return html`
${render()}
`; + })} +
+ ${this.renderMore(this.renderCount)} +
+ `; + } + + protected override updated(_changedProperties: PropertyValues) { + super.updated(_changedProperties); + this.adjustStyle(); + } + + @queryAll(':scope > .microsheet-component-overflow-item') + accessor items!: HTMLDivElement[] & NodeList; + + @query(':scope > .microsheet-component-overflow-more') + accessor more!: HTMLDivElement; + + @state() + accessor renderCount = 0; + + @property({ attribute: false }) + accessor renderItem!: Array<() => TemplateResult>; + + @property({ attribute: false }) + accessor renderMore!: (count: number) => TemplateResult; +} diff --git a/packages/affine/microsheet-data-view/src/core/common/css-variable.ts b/packages/affine/microsheet-data-view/src/core/common/css-variable.ts new file mode 100644 index 000000000000..c4b928d0f0ad --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/css-variable.ts @@ -0,0 +1,60 @@ +export const dataViewCssVariable = () => { + return ` + --data-view-cell-text-size:14px; + --data-view-cell-text-line-height:22px; +`; +}; +export const dataViewCommonStyle = (selector: string) => ` + ${selector}{ + ${dataViewCssVariable()} + } + .with-data-view-css-variable{ + ${dataViewCssVariable()} + font-family: var(--affine-font-family) + } + .dv-pd-2{ + padding:2px; + } + .dv-pd-4{ + padding:4px; + } + .dv-pd-8{ + padding:8px; + } + .dv-hover:hover{ + background-color: var(--affine-hover-color); + cursor: pointer; + } + .dv-icon-16 svg{ + width: 16px; + height: 16px; + color: var(--affine-icon-color); + fill: var(--affine-icon-color); + } + .dv-icon-20 svg{ + width: 20px; + height: 20px; + color: var(--affine-icon-color); + fill: var(--affine-icon-color); + } + .dv-border{ + border: 1px solid var(--affine-border-color); + } + .dv-round-4{ + border-radius: 4px; + } + .dv-round-8{ + border-radius: 8px; + } + .dv-color-2{ + color: var(--affine-text-secondary-color); + } + .dv-shadow-2{ + box-shadow: var(--affine-shadow-2) + } + .dv-divider-h{ + height: 1px; + background-color: var(--affine-divider-color); + margin: 8px 0; + } +`; diff --git a/packages/affine/microsheet-data-view/src/core/common/data-source/base.ts b/packages/affine/microsheet-data-view/src/core/common/data-source/base.ts new file mode 100644 index 000000000000..56cace5831f7 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/data-source/base.ts @@ -0,0 +1,224 @@ +import type { InsertToPosition } from '@blocksuite/affine-shared/utils'; + +import { computed, type ReadonlySignal } from '@preact/signals-core'; + +import type { TType } from '../../logical/index.js'; +import type { PropertyMetaConfig } from '../../property/property-config.js'; +import type { MicrosheetFlags } from '../../types.js'; +import type { ViewConvertConfig } from '../../view/convert.js'; +import type { DataViewDataType, ViewMeta } from '../../view/data-view.js'; +import type { ViewManager } from '../../view-manager/view-manager.js'; +import type { DataViewContextKey } from './context.js'; + +export interface DataSource { + readonly$: ReadonlySignal; + properties$: ReadonlySignal; + featureFlags$: ReadonlySignal; + + cellValueGet(rowId: string, propertyId: string): unknown; + cellRefGet(rowId: string, propertyId: string): unknown; + cellValueGet$( + rowId: string, + propertyId: string + ): ReadonlySignal; + cellValueChange(rowId: string, propertyId: string, value: unknown): void; + + rows$: ReadonlySignal; + rowAdd(InsertToPosition: InsertToPosition | number): string; + rowDelete(ids: string[]): void; + rowMove(rowId: string, position: InsertToPosition): void; + + propertyMetas: PropertyMetaConfig[]; + + propertyNameGet$(propertyId: string): ReadonlySignal; + propertyNameGet(propertyId: string): string; + propertyNameSet(propertyId: string, name: string): void; + + propertyTypeGet(propertyId: string): string | undefined; + propertyTypeGet$(propertyId: string): ReadonlySignal; + propertyTypeSet(propertyId: string, type: string): void; + + propertyDataGet(propertyId: string): Record; + propertyDataGet$( + propertyId: string + ): ReadonlySignal | undefined>; + propertyDataSet(propertyId: string, data: Record): void; + + propertyDataTypeGet(propertyId: string): TType | undefined; + propertyDataTypeGet$(propertyId: string): ReadonlySignal; + + propertyReadonlyGet(propertyId: string): boolean; + propertyReadonlyGet$(propertyId: string): ReadonlySignal; + + propertyMetaGet(type: string): PropertyMetaConfig; + propertyAdd(insertToPosition: InsertToPosition, type?: string): string; + propertyDuplicate(propertyId: string): string; + propertyDelete(id: string): void; + + contextGet(key: DataViewContextKey): T; + + viewConverts: ViewConvertConfig[]; + viewManager: ViewManager; + viewMetas: ViewMeta[]; + viewDataList$: ReadonlySignal; + + viewDataGet(viewId: string): DataViewDataType | undefined; + viewDataGet$(viewId: string): ReadonlySignal; + + viewDataAdd(viewData: DataViewDataType): string; + viewDataDuplicate(id: string): string; + viewDataDelete(viewId: string): void; + viewDataMoveTo(id: string, position: InsertToPosition): void; + viewDataUpdate( + id: string, + updater: (data: ViewData) => Partial + ): void; + + viewMetaGet(type: string): ViewMeta; + viewMetaGet$(type: string): ReadonlySignal; + + viewMetaGetById(viewId: string): ViewMeta; + viewMetaGetById$(viewId: string): ReadonlySignal; +} + +export abstract class DataSourceBase implements DataSource { + context = new Map(); + + abstract featureFlags$: ReadonlySignal; + + abstract properties$: ReadonlySignal; + + abstract propertyMetas: PropertyMetaConfig[]; + + abstract readonly$: ReadonlySignal; + + abstract rows$: ReadonlySignal; + + abstract viewConverts: ViewConvertConfig[]; + + abstract viewDataList$: ReadonlySignal; + + abstract viewManager: ViewManager; + + abstract viewMetas: ViewMeta[]; + + abstract cellValueChange( + rowId: string, + propertyId: string, + value: unknown + ): void; + + abstract cellValueChange( + rowId: string, + propertyId: string, + value: unknown + ): void; + + abstract cellValueGet(rowId: string, propertyId: string): unknown; + + cellValueGet$( + rowId: string, + propertyId: string + ): ReadonlySignal { + return computed(() => this.cellValueGet(rowId, propertyId)); + } + + contextGet(key: DataViewContextKey): T { + return (this.context.get(key.key) as T) ?? key.defaultValue; + } + + contextSet(key: DataViewContextKey, value: T): void { + this.context.set(key.key, value); + } + + abstract propertyAdd( + insertToPosition: InsertToPosition, + type?: string + ): string; + + abstract propertyDataGet(propertyId: string): Record; + + propertyDataGet$( + propertyId: string + ): ReadonlySignal | undefined> { + return computed(() => this.propertyDataGet(propertyId)); + } + + abstract propertyDataSet( + propertyId: string, + data: Record + ): void; + + abstract propertyDataTypeGet(propertyId: string): TType | undefined; + + propertyDataTypeGet$(propertyId: string): ReadonlySignal { + return computed(() => this.propertyDataTypeGet(propertyId)); + } + + abstract propertyDelete(id: string): void; + + abstract propertyDuplicate(propertyId: string): string; + + abstract propertyMetaGet(type: string): PropertyMetaConfig; + + abstract propertyNameGet(propertyId: string): string; + + propertyNameGet$(propertyId: string): ReadonlySignal { + return computed(() => this.propertyNameGet(propertyId)); + } + + abstract propertyNameSet(propertyId: string, name: string): void; + + propertyReadonlyGet(_propertyId: string): boolean { + return false; + } + + propertyReadonlyGet$(propertyId: string): ReadonlySignal { + return computed(() => this.propertyReadonlyGet(propertyId)); + } + + abstract propertyTypeGet(propertyId: string): string; + + propertyTypeGet$(propertyId: string): ReadonlySignal { + return computed(() => this.propertyTypeGet(propertyId)); + } + + abstract propertyTypeSet(propertyId: string, type: string): void; + + abstract rowAdd(InsertToPosition: InsertToPosition | number): string; + + abstract rowDelete(ids: string[]): void; + + abstract rowMove(rowId: string, position: InsertToPosition): void; + + abstract viewDataAdd(viewData: DataViewDataType): string; + + abstract viewDataDelete(viewId: string): void; + + abstract viewDataDuplicate(id: string): string; + + abstract viewDataGet(viewId: string): DataViewDataType; + + viewDataGet$(viewId: string): ReadonlySignal { + return computed(() => this.viewDataGet(viewId)); + } + + abstract viewDataMoveTo(id: string, position: InsertToPosition): void; + + abstract viewDataUpdate( + id: string, + updater: (data: ViewData) => Partial + ): void; + + abstract viewMetaGet(type: string): ViewMeta; + + viewMetaGet$(type: string): ReadonlySignal { + return computed(() => this.viewMetaGet(type)); + } + + abstract viewMetaGetById(viewId: string): ViewMeta; + + viewMetaGetById$(viewId: string): ReadonlySignal { + return computed(() => this.viewMetaGetById(viewId)); + } +} diff --git a/packages/affine/microsheet-data-view/src/core/common/data-source/context.ts b/packages/affine/microsheet-data-view/src/core/common/data-source/context.ts new file mode 100644 index 000000000000..f81b56973c5f --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/data-source/context.ts @@ -0,0 +1,12 @@ +export interface DataViewContextKey { + key: symbol; + defaultValue: T; +} + +export const createContextKey = ( + name: string, + defaultValue: T +): DataViewContextKey => ({ + key: Symbol(name), + defaultValue, +}); diff --git a/packages/affine/microsheet-data-view/src/core/common/data-source/index.ts b/packages/affine/microsheet-data-view/src/core/common/data-source/index.ts new file mode 100644 index 000000000000..ffe27a828483 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/data-source/index.ts @@ -0,0 +1,2 @@ +export * from './base.js'; +export * from './context.js'; diff --git a/packages/affine/microsheet-data-view/src/core/common/detail/detail.ts b/packages/affine/microsheet-data-view/src/core/common/detail/detail.ts new file mode 100644 index 000000000000..29f313e1733e --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/detail/detail.ts @@ -0,0 +1,282 @@ +import { + menu, + popFilterableSimpleMenu, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { + ArrowDownBigIcon, + ArrowUpBigIcon, + PlusIcon, +} from '@blocksuite/icons/lit'; +import { computed } from '@preact/signals-core'; +import { css, nothing, unsafeCSS } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { keyed } from 'lit/directives/keyed.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { html } from 'lit/static-html.js'; + +import type { SingleView } from '../../view-manager/single-view.js'; + +import { + renderUniLit, + type UniComponent, +} from '../../utils/uni-component/uni-component.js'; +import { dataViewCommonStyle } from '../css-variable.js'; +import { DetailSelection } from './selection.js'; + +export type DetailSlotProps = { + view: SingleView; + rowId: string; +}; + +export interface DetailSlots { + header?: UniComponent; + note?: UniComponent; +} + +const styles = css` + ${unsafeCSS(dataViewCommonStyle('affine-microsheet-data-view-record-detail'))} + affine-microsheet-data-view-record-detail { + position: relative; + display: flex; + flex: 1; + flex-direction: column; + padding: 20px; + gap: 12px; + background-color: var(--affine-background-primary-color); + border-radius: 8px; + height: 100%; + width: 100%; + } + + .add-property { + display: flex; + align-items: center; + gap: 4px; + font-size: var(--data-view-cell-text-size); + font-style: normal; + font-weight: 400; + line-height: var(--data-view-cell-text-line-height); + color: var(--affine-text-disable-color); + border-radius: 4px; + padding: 6px 8px 6px 4px; + cursor: pointer; + margin-top: 8px; + width: max-content; + } + + .add-property:hover { + background-color: var(--affine-hover-color); + } + + .add-property .icon { + display: flex; + align-items: center; + } + + .add-property .icon svg { + fill: var(--affine-icon-color); + width: 20px; + height: 20px; + } + + .switch-row { + display: flex; + align-items: center; + justify-content: center; + padding: 2px; + border-radius: 4px; + cursor: pointer; + font-size: 22px; + color: var(--affine-icon-color); + } + + .switch-row:hover { + background-color: var(--affine-hover-color); + } + + .switch-row.disable { + cursor: default; + background: none; + opacity: 0.5; + } +`; + +export class RecordDetail extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = styles; + + _clickAddProperty = () => { + popFilterableSimpleMenu( + popupTargetFromElement(this.addPropertyButton), + this.view.propertyMetas.map(meta => { + return menu.action({ + name: meta.config.name, + prefix: renderUniLit(this.view.IconGet(meta.type)), + select: () => { + this.view.propertyAdd('end', meta.type); + }, + }); + }) + ); + }; + + @property({ attribute: false }) + accessor view!: SingleView; + + properties$ = computed(() => { + return this.view.detailProperties$.value.map(id => + this.view.propertyGet(id) + ); + }); + + selection = new DetailSelection(this); + + private get readonly() { + return this.view.readonly$.value; + } + + private renderHeader() { + const header = this.detailSlots?.header; + if (header) { + const props: DetailSlotProps = { + view: this.view, + rowId: this.rowId, + }; + return renderUniLit(header, props); + } + return undefined; + } + + private renderNote() { + const note = this.detailSlots?.note; + if (note) { + const props: DetailSlotProps = { + view: this.view, + rowId: this.rowId, + }; + return renderUniLit(note, props); + } + return undefined; + } + + override connectedCallback() { + super.connectedCallback(); + + this.disposables.addFromEvent(this, 'click', e => { + e.stopPropagation(); + this.selection.selection = undefined; + }); + //FIXME: simulate as a widget + this.dataset.widgetId = 'affine-detail-widget'; + } + + hasNext() { + return this.view.rowNextGet(this.rowId) != null; + } + + hasPrev() { + return this.view.rowPrevGet(this.rowId) != null; + } + + nextRow() { + const rowId = this.view.rowNextGet(this.rowId); + if (rowId == null) { + return; + } + this.rowId = rowId; + this.requestUpdate(); + } + + prevRow() { + const rowId = this.view.rowPrevGet(this.rowId); + if (rowId == null) { + return; + } + this.rowId = rowId; + this.requestUpdate(); + } + + override render() { + const properties = this.properties$.value; + const upClass = classMap({ + 'switch-row': true, + disable: !this.hasPrev(), + }); + const downClass = classMap({ + 'switch-row': true, + disable: !this.hasNext(), + }); + return html` +
+
+ ${ArrowUpBigIcon()} +
+
+ ${ArrowDownBigIcon()} +
+
+
+ ${keyed(this.rowId, this.renderHeader())} + ${repeat( + properties, + v => v.id, + property => { + return keyed( + this.rowId, + html` ` + ); + } + )} + ${!this.readonly + ? html`
+
${PlusIcon()}
+ Add Property +
` + : nothing} +
+
+ ${keyed(this.rowId, this.renderNote())} + `; + } + + @query('.add-property') + accessor addPropertyButton!: HTMLElement; + + @property({ attribute: false }) + accessor detailSlots: DetailSlots | undefined; + + @property({ attribute: false }) + accessor rowId!: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-microsheet-data-view-record-detail': RecordDetail; + } +} +export const createRecordDetail = (ops: { + view: SingleView; + rowId: string; + detail: DetailSlots; +}) => { + return html` `; +}; diff --git a/packages/affine/microsheet-data-view/src/core/common/detail/field.ts b/packages/affine/microsheet-data-view/src/core/common/detail/field.ts new file mode 100644 index 000000000000..7b78832296eb --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/detail/field.ts @@ -0,0 +1,290 @@ +import { + menu, + popMenu, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { + DeleteIcon, + DuplicateIcon, + MoveLeftIcon, + MoveRightIcon, +} from '@blocksuite/icons/lit'; +import { computed } from '@preact/signals-core'; +import { css } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { createRef } from 'lit/directives/ref.js'; +import { html } from 'lit/static-html.js'; + +import type { + CellRenderProps, + DataViewCellLifeCycle, +} from '../../property/index.js'; +import type { Property } from '../../view-manager/property.js'; +import type { SingleView } from '../../view-manager/single-view.js'; + +import { renderUniLit } from '../../utils/uni-component/uni-component.js'; +import { inputConfig, typeConfig } from '../property-menu.js'; + +export class RecordField extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = css` + affine-microsheet-data-view-record-field { + display: flex; + gap: 12px; + } + + .field-left { + padding: 6px; + display: flex; + height: max-content; + align-items: center; + gap: 6px; + font-size: var(--data-view-cell-text-size); + line-height: var(--data-view-cell-text-line-height); + color: var(--affine-text-secondary-color); + width: 160px; + border-radius: 4px; + cursor: pointer; + user-select: none; + } + + .field-left:hover { + background-color: var(--affine-hover-color); + } + + affine-microsheet-data-view-record-field .icon { + display: flex; + align-items: center; + width: 16px; + height: 16px; + } + + affine-microsheet-data-view-record-field .icon svg { + width: 16px; + height: 16px; + fill: var(--affine-icon-color); + } + + .filed-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .field-content { + padding: 6px 8px; + border-radius: 4px; + flex: 1; + cursor: pointer; + display: flex; + align-items: center; + border: 1px solid transparent; + } + + .field-content .affine-microsheet-number { + text-align: left; + justify-content: start; + } + + .field-content:hover { + background-color: var(--affine-hover-color); + } + + .field-content.is-editing { + box-shadow: 0px 0px 0px 2px rgba(30, 150, 235, 0.3); + } + + .field-content.is-focus { + border: 1px solid var(--affine-primary-color); + } + + .field-content.empty::before { + content: 'Empty'; + color: var(--affine-text-disable-color); + font-size: 14px; + line-height: 22px; + } + `; + + private _cell = createRef(); + + _click = (e: MouseEvent) => { + e.stopPropagation(); + if (this.readonly) return; + + this.changeEditing(true); + }; + + _clickLeft = (e: MouseEvent) => { + if (this.readonly) return; + const ele = e.currentTarget as HTMLElement; + const properties = this.view.detailProperties$.value; + popMenu(popupTargetFromElement(ele), { + options: { + items: [ + menu.group({ + name: 'Column Prop Group ', + items: [inputConfig(this.column), typeConfig(this.column)], + }), + menu.group({ + items: [ + menu.action({ + name: 'Move Up', + prefix: html`
+ ${MoveLeftIcon()} +
`, + hide: () => + properties.findIndex(v => v === this.column.id) === 0, + select: () => { + const index = properties.findIndex(v => v === this.column.id); + const targetId = properties[index - 1]; + if (!targetId) { + return; + } + this.view.propertyMove(this.column.id, { + id: targetId, + before: true, + }); + }, + }), + menu.action({ + name: 'Move Down', + prefix: html`
+ ${MoveRightIcon()} +
`, + hide: () => + properties.findIndex(v => v === this.column.id) === + properties.length - 1, + select: () => { + const index = properties.findIndex(v => v === this.column.id); + const targetId = properties[index + 1]; + if (!targetId) { + return; + } + this.view.propertyMove(this.column.id, { + id: targetId, + before: false, + }); + }, + }), + ], + }), + menu.group({ + name: 'operation', + items: [ + menu.action({ + name: 'Duplicate', + prefix: DuplicateIcon(), + hide: () => + !this.column.duplicate || this.column.type$.value === 'title', + select: () => { + this.column.duplicate?.(); + }, + }), + menu.action({ + name: 'Delete', + prefix: DeleteIcon(), + hide: () => + !this.column.delete || this.column.type$.value === 'title', + select: () => { + this.column.delete?.(); + }, + class: 'delete-item', + }), + ], + }), + ], + }, + }); + }; + + @property({ attribute: false }) + accessor column!: Property; + + @property({ attribute: false }) + accessor rowId!: string; + + cell$ = computed(() => { + return this.column.cellGet(this.rowId); + }); + + changeEditing = (editing: boolean) => { + const selection = this.closest( + 'affine-microsheet-data-view-record-detail' + )?.selection; + if (selection) { + selection.selection = { + propertyId: this.column.id, + isEditing: editing, + }; + } + }; + + get cell(): DataViewCellLifeCycle | undefined { + return this._cell.value; + } + + private get readonly() { + return this.view.readonly$.value; + } + + override render() { + const column = this.column; + + const props: CellRenderProps = { + cell: this.cell$.value, + isEditing: this.editing, + selectCurrentCell: this.changeEditing, + }; + const renderer = this.column.renderer$.value; + if (!renderer) { + return; + } + const { view, edit } = renderer; + const contentClass = classMap({ + 'field-content': true, + empty: !this.editing && this.cell$.value.isEmpty$.value, + 'is-editing': this.editing, + 'is-focus': this.isFocus, + }); + return html` +
+
+
+ +
+
${column.name$.value}
+
+
+
+ ${renderUniLit(this.editing && edit ? edit : view, props, { + ref: this._cell, + class: 'kanban-cell', + })} +
+ `; + } + + @state() + accessor editing = false; + + @state() + accessor isFocus = false; + + @property({ attribute: false }) + accessor view!: SingleView; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-microsheet-data-view-record-field': RecordField; + } +} diff --git a/packages/affine/microsheet-data-view/src/core/common/detail/selection.ts b/packages/affine/microsheet-data-view/src/core/common/detail/selection.ts new file mode 100644 index 000000000000..52b08c574b6d --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/detail/selection.ts @@ -0,0 +1,158 @@ +import type { KanbanCard } from '../../../view-presets/kanban/card.js'; +import type { KanbanCardSelection } from '../../../view-presets/kanban/types.js'; +import type { RecordDetail } from './detail.js'; + +import { KanbanCell } from '../../../view-presets/kanban/cell.js'; +import { RecordField } from './field.js'; + +type DetailViewSelection = { + propertyId: string; + isEditing: boolean; +}; + +export class DetailSelection { + _selection?: DetailViewSelection; + + onSelect = (selection?: DetailViewSelection) => { + const old = this._selection; + if (old) { + this.blur(old); + } + this._selection = selection; + if (selection) { + this.focus(selection); + } + }; + + get selection(): DetailViewSelection | undefined { + return this._selection; + } + + set selection(selection: DetailViewSelection | undefined) { + if (!selection) { + this.onSelect(); + return; + } + if (selection.isEditing) { + const container = this.getFocusCellContainer(selection); + const cell = container?.cell; + const isEditing = cell + ? cell.beforeEnterEditMode() + ? selection.isEditing + : false + : false; + this.onSelect({ + propertyId: selection.propertyId, + isEditing, + }); + } else { + this.onSelect(selection); + } + } + + constructor(private viewEle: RecordDetail) {} + + blur(selection: DetailViewSelection) { + const container = this.getFocusCellContainer(selection); + if (!container) { + return; + } + + container.isFocus = false; + const cell = container.cell; + + if (selection.isEditing) { + requestAnimationFrame(() => { + cell?.onExitEditMode(); + }); + if (cell?.blurCell()) { + container.blur(); + } + container.editing = false; + } else { + container.blur(); + } + } + + deleteProperty() { + // + } + + focus(selection: DetailViewSelection) { + const container = this.getFocusCellContainer(selection); + if (!container) { + return; + } + container.isFocus = true; + const cell = container.cell; + if (selection.isEditing) { + cell?.onEnterEditMode(); + if (cell?.focusCell()) { + container.focus(); + } + container.editing = true; + } else { + container.focus(); + } + } + + focusDown() { + const selection = this.selection; + if (!selection || selection?.isEditing) { + return; + } + const nextContainer = + this.getFocusCellContainer(selection)?.nextElementSibling; + if (nextContainer instanceof KanbanCell) { + this.selection = { + propertyId: nextContainer.column.id, + isEditing: false, + }; + } + } + + focusFirstCell() { + const firstId = this.viewEle.querySelector( + 'affine-microsheet-data-view-record-field' + )?.column.id; + if (firstId) { + this.selection = { + propertyId: firstId, + isEditing: true, + }; + } + } + + focusUp() { + const selection = this.selection; + if (!selection || selection?.isEditing) { + return; + } + const preContainer = + this.getFocusCellContainer(selection)?.previousElementSibling; + if (preContainer instanceof RecordField) { + this.selection = { + propertyId: preContainer.column.id, + isEditing: false, + }; + } + } + + getFocusCellContainer(selection: DetailViewSelection) { + return this.viewEle.querySelector( + `affine-microsheet-data-view-record-field[data-column-id="${selection.propertyId}"]` + ) as RecordField | undefined; + } + + getSelectCard(selection: KanbanCardSelection) { + const { groupKey, cardId } = selection.cards[0]; + + return this.viewEle + .querySelector( + `affine-microsheet-data-view-kanban-group[data-key="${groupKey}"]` + ) + ?.querySelector( + `affine-microsheet-data-view-kanban-card[data-card-id="${cardId}"]` + ) as KanbanCard | undefined; + } +} diff --git a/packages/affine/microsheet-data-view/src/core/common/group-by.ts b/packages/affine/microsheet-data-view/src/core/common/group-by.ts new file mode 100644 index 000000000000..e2186fb4df76 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/group-by.ts @@ -0,0 +1,19 @@ +import type { PropertyMetaConfig } from '../property/property-config.js'; +import type { GroupBy } from './types.js'; + +import { groupByMatcher } from './group-by/matcher.js'; + +export const defaultGroupBy = ( + propertyMeta: PropertyMetaConfig, + propertyId: string, + data: NonNullable +): GroupBy | undefined => { + const name = groupByMatcher.match(propertyMeta.config.type(data))?.name; + return name != null + ? { + type: 'groupBy', + columnId: propertyId, + name: name, + } + : undefined; +}; diff --git a/packages/affine/microsheet-data-view/src/core/common/group-by/define.ts b/packages/affine/microsheet-data-view/src/core/common/group-by/define.ts new file mode 100644 index 000000000000..5ec6c7b0d0d4 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/group-by/define.ts @@ -0,0 +1,171 @@ +import type { GroupByConfig } from './types.js'; + +import { tBoolean, tNumber, tString, tTag } from '../../logical/data-type.js'; +import { MatcherCreator } from '../../logical/matcher.js'; +import { isTArray, tArray } from '../../logical/typesystem.js'; +import { createUniComponentFromWebComponent } from '../../utils/uni-component/uni-component.js'; +import { BooleanGroupView } from './renderer/boolean-group.js'; +import { NumberGroupView } from './renderer/number-group.js'; +import { SelectGroupView } from './renderer/select-group.js'; +import { StringGroupView } from './renderer/string-group.js'; + +const groupByMatcherCreator = new MatcherCreator(); +const ungroups = { + key: 'Ungroups', + value: null, +}; +export const groupByMatchers = [ + groupByMatcherCreator.createMatcher(tTag.create(), { + name: 'select', + groupName: (type, value) => { + if (tTag.is(type) && type.data) { + return type.data.tags.find(v => v.id === value)?.value ?? ''; + } + return ''; + }, + defaultKeys: type => { + if (tTag.is(type) && type.data) { + return [ + ungroups, + ...type.data.tags.map(v => ({ + key: v.id, + value: v.id, + })), + ]; + } + return [ungroups]; + }, + valuesGroup: (value, _type) => { + if (value == null) { + return [ungroups]; + } + return [ + { + key: `${value}`, + value, + }, + ]; + }, + view: createUniComponentFromWebComponent(SelectGroupView), + }), + groupByMatcherCreator.createMatcher(tArray(tTag.create()), { + name: 'multi-select', + groupName: (type, value) => { + if (tTag.is(type) && type.data) { + return type.data.tags.find(v => v.id === value)?.value ?? ''; + } + return ''; + }, + defaultKeys: type => { + if (isTArray(type) && tTag.is(type.ele) && type.ele.data) { + return [ + ungroups, + ...type.ele.data.tags.map(v => ({ + key: v.id, + value: v.id, + })), + ]; + } + return [ungroups]; + }, + valuesGroup: (value, _type) => { + if (value == null) { + return [ungroups]; + } + if (Array.isArray(value)) { + if (value.length) { + return value.map(id => ({ + key: `${id}`, + value: id, + })); + } + } + return [ungroups]; + }, + addToGroup: (value, old) => { + if (value == null) { + return old; + } + return Array.isArray(old) ? [...old, value] : [value]; + }, + removeFromGroup: (value, old) => { + if (Array.isArray(old)) { + return old.filter(v => v !== value); + } + return old; + }, + view: createUniComponentFromWebComponent(SelectGroupView), + }), + groupByMatcherCreator.createMatcher(tString.create(), { + name: 'text', + groupName: (_type, value) => { + return `${value ?? ''}`; + }, + defaultKeys: _type => { + return [ungroups]; + }, + valuesGroup: (value, _type) => { + if (!value) { + return [ungroups]; + } + return [ + { + key: `g:${value}`, + value, + }, + ]; + }, + view: createUniComponentFromWebComponent(StringGroupView), + }), + groupByMatcherCreator.createMatcher(tNumber.create(), { + name: 'number', + groupName: (_type, value) => { + return `${value ?? ''}`; + }, + defaultKeys: _type => { + return [ungroups]; + }, + valuesGroup: (value, _type) => { + if (typeof value !== 'number') { + return [ungroups]; + } + return [ + { + key: `g:${Math.floor(value / 10)}`, + value: Math.floor(value / 10), + }, + ]; + }, + addToGroup: value => (typeof value === 'number' ? value * 10 : undefined), + view: createUniComponentFromWebComponent(NumberGroupView), + }), + groupByMatcherCreator.createMatcher(tBoolean.create(), { + name: 'boolean', + groupName: (_type, value) => { + return `${value?.toString() ?? ''}`; + }, + defaultKeys: _type => { + return [ + { key: 'true', value: true }, + { key: 'false', value: false }, + ]; + }, + valuesGroup: (value, _type) => { + if (typeof value !== 'boolean') { + return [ + { + key: 'false', + value: false, + }, + ]; + } + return [ + { + key: value.toString(), + value: value, + }, + ]; + }, + view: createUniComponentFromWebComponent(BooleanGroupView), + }), +]; diff --git a/packages/affine/microsheet-data-view/src/core/common/group-by/group-title.ts b/packages/affine/microsheet-data-view/src/core/common/group-by/group-title.ts new file mode 100644 index 000000000000..859666dab7f8 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/group-by/group-title.ts @@ -0,0 +1,120 @@ +import { MoreHorizontalIcon, PlusIcon } from '@blocksuite/icons/lit'; +import { nothing } from 'lit'; +import { html } from 'lit/static-html.js'; + +import type { GroupData } from './helper.js'; +import type { GroupRenderProps } from './types.js'; + +import { renderUniLit } from '../../utils/uni-component/uni-component.js'; + +function GroupHeaderCount(group: GroupData) { + const cards = group.rows; + if (!cards.length) { + return; + } + return html`
${cards.length}
`; +} + +export function GroupTitle( + groupData: GroupData, + ops: { + readonly: boolean; + clickAdd: (evt: MouseEvent) => void; + clickOps: (evt: MouseEvent) => void; + } +) { + const data = groupData.manager.config$.value; + if (!data) return nothing; + + const icon = + groupData.value == null + ? '' + : html` `; + const props: GroupRenderProps = { + value: groupData.value, + data: groupData.property.data$.value, + updateData: groupData.manager.updateData, + updateValue: value => groupData.manager.updateValue(groupData.rows, value), + readonly: ops.readonly, + }; + + return html` + +
+ ${icon} ${renderUniLit(data.view, props)} ${GroupHeaderCount(groupData)} +
+ ${ops.readonly + ? nothing + : html`
+
+ ${PlusIcon()} +
+
+ ${MoreHorizontalIcon()} +
+
`} + `; +} diff --git a/packages/affine/microsheet-data-view/src/core/common/group-by/helper.ts b/packages/affine/microsheet-data-view/src/core/common/group-by/helper.ts new file mode 100644 index 000000000000..c2291b15c923 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/group-by/helper.ts @@ -0,0 +1,280 @@ +import { + insertPositionToIndex, + type InsertToPosition, +} from '@blocksuite/affine-shared/utils'; +import { computed, type ReadonlySignal } from '@preact/signals-core'; + +import type { TType } from '../../logical/typesystem.js'; +import type { Property } from '../../view-manager/property.js'; +import type { SingleView } from '../../view-manager/single-view.js'; +import type { GroupBy, GroupProperty } from '../types.js'; + +import { groupByMatcher } from './matcher.js'; + +export type GroupData = { + manager: GroupManager; + property: Property; + key: string; + name: string; + type: TType; + value: unknown; + rows: string[]; +}; + +export class GroupManager { + config$ = computed(() => { + const groupBy = this.groupBy$.value; + if (!groupBy) { + return; + } + const result = groupByMatcher.find(v => v.data.name === groupBy.name); + if (!result) { + return; + } + return result.data; + }); + + property$ = computed(() => { + const groupBy = this.groupBy$.value; + if (!groupBy) { + return; + } + return this.viewManager.propertyGet(groupBy.columnId); + }); + + staticGroupDataMap$ = computed< + Record> | undefined + >(() => { + const config = this.config$.value; + const property = this.property$.value; + const tType = property?.dataType$.value; + if (!config || !tType || !property) { + return; + } + return Object.fromEntries( + config.defaultKeys(tType).map(({ key, value }) => [ + key, + { + key, + property, + name: config.groupName(tType, value), + manager: this, + type: tType, + value, + }, + ]) + ); + }); + + groupDataMap$ = computed | undefined>(() => { + const staticGroupMap = this.staticGroupDataMap$.value; + const config = this.config$.value; + const groupBy = this.groupBy$.value; + const property = this.property$.value; + const tType = property?.dataType$.value; + if (!staticGroupMap || !config || !groupBy || !tType || !property) { + return; + } + const groupMap: Record = Object.fromEntries( + Object.entries(staticGroupMap).map(([k, v]) => [k, { ...v, rows: [] }]) + ); + this.viewManager.rows$.value.forEach(id => { + const value = this.viewManager.cellJsonValueGet(id, groupBy.columnId); + const keys = config.valuesGroup(value, tType); + keys.forEach(({ key, value }) => { + if (!groupMap[key]) { + groupMap[key] = { + key, + property: property, + name: config.groupName(tType, value), + manager: this, + value, + rows: [], + type: tType, + }; + } + groupMap[key].rows.push(id); + }); + }); + return groupMap; + }); + + groupsDataList$ = computed(() => { + const groupMap = this.groupDataMap$.value; + if (!groupMap) { + return; + } + const sortedGroup = this.ops.sortGroup(Object.keys(groupMap)); + sortedGroup.forEach(key => { + groupMap[key].rows = this.ops.sortRow(key, groupMap[key].rows); + }); + return sortedGroup.map(key => groupMap[key]); + }); + + updateData = (data: NonNullable) => { + const propertyId = this.propertyId; + if (!propertyId) { + return; + } + this.viewManager.propertyDataSet(propertyId, data); + }; + + get addGroup() { + const type = this.property$.value?.type$.value; + if (!type) { + return; + } + return this.viewManager.propertyMetaGet(type)?.config.addGroup; + } + + get propertyId() { + return this.groupBy$.value?.columnId; + } + + constructor( + private groupBy$: ReadonlySignal, + private viewManager: SingleView, + private ops: { + sortGroup: (keys: string[]) => string[]; + sortRow: (groupKey: string, rowIds: string[]) => string[]; + changeGroupSort: (keys: string[]) => void; + changeRowSort: ( + groupKeys: string[], + groupKey: string, + keys: string[] + ) => void; + } + ) {} + + addToGroup(rowId: string, key: string) { + const groupMap = this.groupDataMap$.value; + const propertyId = this.propertyId; + if (!groupMap || !propertyId) { + return; + } + const addTo = this.config$.value?.addToGroup ?? (value => value); + const newValue = addTo( + groupMap[key].value, + this.viewManager.cellJsonValueGet(rowId, propertyId) + ); + this.viewManager.cellValueSet(rowId, propertyId, newValue); + } + + changeCardSort(groupKey: string, cardIds: string[]) { + const groups = this.groupsDataList$.value; + if (!groups) { + return; + } + this.ops.changeRowSort( + groups.map(v => v.key), + groupKey, + cardIds + ); + } + + changeGroupSort(keys: string[]) { + this.ops.changeGroupSort(keys); + } + + defaultGroupProperty(key: string): GroupProperty { + return { + key, + hide: false, + manuallyCardSort: [], + }; + } + + moveCardTo( + rowId: string, + fromGroupKey: string | undefined, + toGroupKey: string, + position: InsertToPosition + ) { + const groupMap = this.groupDataMap$.value; + if (!groupMap) { + return; + } + if (fromGroupKey !== toGroupKey) { + const propertyId = this.propertyId; + if (!propertyId) { + return; + } + const remove = this.config$.value?.removeFromGroup ?? (() => undefined); + const group = fromGroupKey != null ? groupMap[fromGroupKey] : undefined; + let newValue: unknown = undefined; + if (group) { + newValue = remove( + group.value, + this.viewManager.cellJsonValueGet(rowId, propertyId) + ); + } + const addTo = this.config$.value?.addToGroup ?? (value => value); + newValue = addTo(groupMap[toGroupKey].value, newValue); + this.viewManager.cellValueSet(rowId, propertyId, newValue); + } + const rows = groupMap[toGroupKey].rows.filter(id => id !== rowId); + const index = insertPositionToIndex(position, rows, id => id); + rows.splice(index, 0, rowId); + this.changeCardSort(toGroupKey, rows); + } + + moveGroupTo(groupKey: string, position: InsertToPosition) { + const groups = this.groupsDataList$.value; + if (!groups) { + return; + } + const keys = groups.map(v => v.key); + keys.splice( + keys.findIndex(key => key === groupKey), + 1 + ); + const index = insertPositionToIndex(position, keys, key => key); + keys.splice(index, 0, groupKey); + this.changeGroupSort(keys); + } + + removeFromGroup(rowId: string, key: string) { + const groupMap = this.groupDataMap$.value; + if (!groupMap) { + return; + } + const propertyId = this.propertyId; + if (!propertyId) { + return; + } + const remove = this.config$.value?.removeFromGroup ?? (() => undefined); + const newValue = remove( + groupMap[key].value, + this.viewManager.cellJsonValueGet(rowId, propertyId) + ); + this.viewManager.cellValueSet(rowId, propertyId, newValue); + } + + updateValue(rows: string[], value: unknown) { + const propertyId = this.propertyId; + if (!propertyId) { + return; + } + rows.forEach(id => { + this.viewManager.cellValueSet(id, propertyId, value); + }); + } +} + +export const sortByManually = ( + arr: T[], + getId: (v: T) => string, + ids: string[] +) => { + const map = new Map(arr.map(v => [getId(v), v])); + const result: T[] = []; + for (const id of ids) { + const value = map.get(id); + if (value) { + map.delete(id); + result.push(value); + } + } + result.push(...map.values()); + return result; +}; diff --git a/packages/affine/microsheet-data-view/src/core/common/group-by/matcher.ts b/packages/affine/microsheet-data-view/src/core/common/group-by/matcher.ts new file mode 100644 index 000000000000..53789fd141f3 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/group-by/matcher.ts @@ -0,0 +1,6 @@ +import type { GroupByConfig } from './types.js'; + +import { Matcher } from '../../logical/matcher.js'; +import { groupByMatchers } from './define.js'; + +export const groupByMatcher = new Matcher(groupByMatchers); diff --git a/packages/affine/microsheet-data-view/src/core/common/group-by/renderer/base.ts b/packages/affine/microsheet-data-view/src/core/common/group-by/renderer/base.ts new file mode 100644 index 000000000000..608762b8ec3f --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/group-by/renderer/base.ts @@ -0,0 +1,25 @@ +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { property } from 'lit/decorators.js'; + +import type { GroupRenderProps } from '../types.js'; + +export class BaseGroup, Value> + extends SignalWatcher(WithDisposable(ShadowlessElement)) + implements GroupRenderProps +{ + @property({ attribute: false }) + accessor data!: Data; + + @property({ attribute: false }) + accessor readonly!: boolean; + + @property({ attribute: false }) + accessor updateData: ((data: Data) => void) | undefined = undefined; + + @property({ attribute: false }) + accessor updateValue: ((value: Value) => void) | undefined = undefined; + + @property({ attribute: false }) + accessor value!: Value; +} diff --git a/packages/affine/microsheet-data-view/src/core/common/group-by/renderer/boolean-group.ts b/packages/affine/microsheet-data-view/src/core/common/group-by/renderer/boolean-group.ts new file mode 100644 index 000000000000..04655ebfb9d2 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/group-by/renderer/boolean-group.ts @@ -0,0 +1,25 @@ +import { CheckBoxCkeckSolidIcon, CheckBoxUnIcon } from '@blocksuite/icons/lit'; +import { css, html } from 'lit'; + +import { BaseGroup } from './base.js'; + +export class BooleanGroupView extends BaseGroup, boolean> { + static override styles = css` + .data-view-group-title-boolean-view { + display: flex; + align-items: center; + } + .data-view-group-title-boolean-view svg { + width: 20px; + height: 20px; + } + `; + + protected override render(): unknown { + return html`
+ ${this.value + ? CheckBoxCkeckSolidIcon({ style: `color:#1E96EB` }) + : CheckBoxUnIcon()} +
`; + } +} diff --git a/packages/affine/microsheet-data-view/src/core/common/group-by/renderer/number-group.ts b/packages/affine/microsheet-data-view/src/core/common/group-by/renderer/number-group.ts new file mode 100644 index 000000000000..04a986825629 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/group-by/renderer/number-group.ts @@ -0,0 +1,65 @@ +import { + menu, + popMenu, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { css, html } from 'lit'; + +import { BaseGroup } from './base.js'; + +export class NumberGroupView extends BaseGroup, number> { + static override styles = css` + .data-view-group-title-number-view { + border-radius: 8px; + padding: 4px 8px; + width: max-content; + cursor: pointer; + } + + .data-view-group-title-number-view:hover { + background-color: var(--affine-hover-color); + } + `; + + private _click = () => { + if (this.readonly) { + return; + } + popMenu(popupTargetFromElement(this), { + options: { + items: [ + menu.input({ + initialValue: this.value ? `${this.value * 10}` : '', + onComplete: text => { + const num = Number.parseFloat(text); + if (Number.isNaN(num)) { + return; + } + this.updateValue?.(num); + }, + }), + ], + }, + }); + }; + + protected override render(): unknown { + if (this.value == null) { + return html`
Ungroups
`; + } + if (this.value >= 10) { + return html`
+ >= 100 +
`; + } + return html`
+ ${this.value * 10} - ${this.value * 10 + 9} +
`; + } +} diff --git a/packages/affine/microsheet-data-view/src/core/common/group-by/renderer/select-group.ts b/packages/affine/microsheet-data-view/src/core/common/group-by/renderer/select-group.ts new file mode 100644 index 000000000000..4b628fc80fd4 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/group-by/renderer/select-group.ts @@ -0,0 +1,119 @@ +import { + menu, + popMenu, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { css, html } from 'lit'; +import { classMap } from 'lit/directives/class-map.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import type { SelectTag } from '../../../utils/tags/multi-tag-select.js'; + +import { selectOptionColors } from '../../../utils/tags/colors.js'; +import { BaseGroup } from './base.js'; + +export class SelectGroupView extends BaseGroup< + { + options: SelectTag[]; + }, + string +> { + static override styles = css` + data-view-group-title-select-view { + overflow: hidden; + } + + .data-view-group-title-select-view { + width: 100%; + cursor: pointer; + } + + .data-view-group-title-select-view.readonly { + cursor: inherit; + } + + .tag { + padding: 0 8px; + border-radius: 4px; + font-size: var(--data-view-cell-text-size); + line-height: var(--data-view-cell-text-line-height); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + `; + + private _click = () => { + if (this.readonly) { + return; + } + popMenu(popupTargetFromElement(this), { + options: { + items: [ + menu.input({ + initialValue: this.tag?.value ?? '', + onComplete: text => { + this.updateTag({ value: text }); + }, + }), + ...selectOptionColors.map(({ color, name }) => { + const styles = styleMap({ + backgroundColor: color, + borderRadius: '50%', + width: '20px', + height: '20px', + }); + return menu.action({ + name: name, + isSelected: this.tag?.color === color, + prefix: html`
`, + select: () => { + this.updateTag({ color }); + }, + }); + }), + ], + }, + }); + }; + + get tag() { + return this.data.options.find(v => v.id === this.value); + } + + protected override render(): unknown { + const tag = this.tag; + if (!tag) { + return html`
+ Ungroups +
`; + } + const style = styleMap({ + backgroundColor: tag.color, + }); + const classList = classMap({ + 'data-view-group-title-select-view': true, + readonly: this.readonly, + }); + return html`
+
${tag.value}
+
`; + } + + updateTag(tag: Partial) { + this.updateData?.({ + ...this.data, + options: this.data.options.map(v => { + if (v.id === this.value) { + return { + ...v, + ...tag, + }; + } + return v; + }), + }); + } +} diff --git a/packages/affine/microsheet-data-view/src/core/common/group-by/renderer/string-group.ts b/packages/affine/microsheet-data-view/src/core/common/group-by/renderer/string-group.ts new file mode 100644 index 000000000000..8d1f6df0c68b --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/group-by/renderer/string-group.ts @@ -0,0 +1,53 @@ +import { + menu, + popMenu, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { css, html } from 'lit'; + +import { BaseGroup } from './base.js'; + +export class StringGroupView extends BaseGroup, string> { + static override styles = css` + .data-view-group-title-string-view { + border-radius: 8px; + padding: 4px 8px; + width: max-content; + cursor: pointer; + } + + .data-view-group-title-string-view:hover { + background-color: var(--affine-hover-color); + } + `; + + private _click = () => { + if (this.readonly) { + return; + } + popMenu(popupTargetFromElement(this), { + options: { + items: [ + menu.input({ + initialValue: this.value ?? '', + onComplete: text => { + this.updateValue?.(text); + }, + }), + ], + }, + }); + }; + + protected override render(): unknown { + if (!this.value) { + return html`
Ungroups
`; + } + return html`
+ ${this.value} +
`; + } +} diff --git a/packages/affine/microsheet-data-view/src/core/common/group-by/setting.ts b/packages/affine/microsheet-data-view/src/core/common/group-by/setting.ts new file mode 100644 index 000000000000..1b6811af7bba --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/group-by/setting.ts @@ -0,0 +1,306 @@ +import type { PropertyValues } from 'lit'; + +import { + menu, + type MenuConfig, + type MenuOptions, + popMenu, + type PopupTarget, +} from '@blocksuite/affine-components/context-menu'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { DeleteIcon } from '@blocksuite/icons/lit'; +import { css, html, unsafeCSS } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; +import Sortable from 'sortablejs'; + +import type { + KanbanViewData, + TableViewData, +} from '../../../view-presets/index.js'; +import type { SingleView } from '../../view-manager/single-view.js'; +import type { GroupRenderProps } from './types.js'; + +import { KanbanSingleView } from '../../../view-presets/kanban/kanban-view-manager.js'; +import { TableSingleView } from '../../../view-presets/table/table-view-manager.js'; +import { renderUniLit } from '../../utils/uni-component/uni-component.js'; +import { dataViewCssVariable } from '../css-variable.js'; +import { groupByMatcher } from './matcher.js'; + +export class GroupSetting extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = css` + data-view-group-setting { + display: flex; + flex-direction: column; + gap: 4px; + ${unsafeCSS(dataViewCssVariable())}; + } + + .group-item { + display: flex; + padding: 4px 12px; + position: relative; + cursor: grab; + } + + .group-item-drag-bar { + width: 4px; + height: 12px; + border-radius: 1px; + background-color: #efeff0; + position: absolute; + left: 4px; + top: 0; + bottom: 0; + margin: auto; + } + + .group-item:hover .group-item-drag-bar { + background-color: #c0bfc1; + } + `; + + override connectedCallback() { + super.connectedCallback(); + this._disposables.addFromEvent(this, 'pointerdown', e => { + e.stopPropagation(); + }); + } + + protected override firstUpdated(_changedProperties: PropertyValues) { + super.firstUpdated(_changedProperties); + const sortable = new Sortable(this.groupContainer, { + animation: 150, + group: `group-sort-${this.view.id}`, + onEnd: evt => { + const groupManager = this.view.groupManager; + const oldGroups = groupManager.groupsDataList$.value; + if (!oldGroups) { + return; + } + const groups = [...oldGroups]; + const index = evt.oldIndex ?? -1; + const from = groups[index]; + groups.splice(index, 1); + const to = groups[evt.newIndex ?? -1]; + groupManager.moveGroupTo( + from.key, + to + ? { + before: true, + id: to.key, + } + : 'end' + ); + }, + }); + this._disposables.add({ + dispose: () => sortable.destroy(), + }); + } + + protected override render(): unknown { + const groups = this.view.groupManager.groupsDataList$.value; + if (!groups) { + return; + } + return html` +
+
+ Groups +
+
+
+
+ ${repeat( + groups, + group => group.key, + group => { + const props: GroupRenderProps = { + value: group.value, + data: group.property.data$.value, + readonly: true, + }; + const config = group.manager.config$.value; + return html`
+
+
+ ${renderUniLit(config?.view, props)} +
+
+
`; + } + )} +
+ `; + } + + @query('.group-sort-setting') + accessor groupContainer!: HTMLElement; + + @property({ attribute: false }) + accessor view!: TableSingleView | KanbanSingleView; +} + +export const selectGroupByProperty = ( + view: SingleView, + ops?: { + onSelect?: (id?: string) => void; + onClose?: () => void; + onBack?: () => void; + } +): MenuOptions => { + return { + onClose: ops?.onClose, + title: { + text: 'Group by', + onBack: ops?.onBack, + }, + items: [ + ...view.propertiesWithoutFilter$.value + .filter(id => { + if (view.propertyGet(id).type$.value === 'title') { + return false; + } + return !!groupByMatcher.match(view.propertyGet(id).dataType$.value); + }) + .map(id => { + const property = view.propertyGet(id); + return menu.action({ + name: property.name$.value, + isSelected: view.data$.value?.groupBy?.columnId === id, + prefix: html` `, + select: () => { + if ( + view instanceof TableSingleView || + view instanceof KanbanSingleView + ) { + view.changeGroup(id); + ops?.onSelect?.(id); + } + }, + }); + }), + menu.group({ + items: [ + menu.action({ + prefix: DeleteIcon(), + hide: () => + view instanceof KanbanSingleView || + view.data$.value?.groupBy == null, + class: 'delete-item', + name: 'Remove Grouping', + select: () => { + if (view instanceof TableSingleView) { + view.changeGroup(undefined); + ops?.onSelect?.(); + } + }, + }), + ], + }), + ], + }; +}; +export const popSelectGroupByProperty = ( + target: PopupTarget, + view: SingleView, + ops?: { + onSelect?: () => void; + onClose?: () => void; + onBack?: () => void; + } +) => { + popMenu(target, { + options: selectGroupByProperty(view, ops), + }); +}; +export const popGroupSetting = ( + target: PopupTarget, + view: SingleView, + onBack: () => void +) => { + const groupBy = view.data$.value?.groupBy; + if (groupBy == null) { + return; + } + const type = view.propertyTypeGet(groupBy.columnId); + if (!type) { + return; + } + const icon = view.IconGet(type); + const menuHandler = popMenu(target, { + options: { + title: { + text: 'Group', + onBack: onBack, + }, + items: [ + menu.group({ + items: [ + menu.subMenu({ + name: 'Group By', + postfix: html` +
+ ${renderUniLit(icon, {})} + ${view.propertyNameGet(groupBy.columnId)} +
+ `, + label: () => html` +
+ Group By +
+ `, + options: selectGroupByProperty(view, { + onSelect: () => { + menuHandler.close(); + popGroupSetting(target, view, onBack); + }, + }), + }), + ], + }), + menu.group({ + items: [ + menu => + html` `, + ], + }), + menu.group({ + items: [ + menu.action({ + name: 'Remove grouping', + prefix: DeleteIcon(), + class: 'delete-item', + hide: () => !(view instanceof TableSingleView), + select: () => { + if (view instanceof TableSingleView) { + view.changeGroup(undefined); + } + }, + }), + ], + }), + ], + }, + }); +}; diff --git a/packages/affine/microsheet-data-view/src/core/common/group-by/types.ts b/packages/affine/microsheet-data-view/src/core/common/group-by/types.ts new file mode 100644 index 000000000000..ac014e4cc139 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/group-by/types.ts @@ -0,0 +1,32 @@ +import type { TType } from '../../logical/index.js'; +import type { UniComponent } from '../../utils/index.js'; + +export interface GroupRenderProps< + Data extends NonNullable = NonNullable, + Value = unknown, +> { + data: Data; + updateData?: (data: Data) => void; + value: Value; + updateValue?: (value: Value) => void; + readonly: boolean; +} + +export type GroupByConfig = { + name: string; + groupName: (type: TType, value: unknown) => string; + defaultKeys: (type: TType) => { + key: string; + value: unknown; + }[]; + valuesGroup: ( + value: unknown, + type: TType + ) => { + key: string; + value: unknown; + }[]; + addToGroup?: (value: unknown, oldValue: unknown) => unknown; + removeFromGroup?: (value: unknown, oldValue: unknown) => unknown; + view: UniComponent; +}; diff --git a/packages/affine/microsheet-data-view/src/core/common/index.ts b/packages/affine/microsheet-data-view/src/core/common/index.ts new file mode 100644 index 000000000000..4d2b453875ed --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/index.ts @@ -0,0 +1,10 @@ +export * from './ast.js'; +export * from './css-variable.js'; +export * from './data-source/index.js'; +export * from './detail/detail.js'; +export * from './group-by.js'; +export * from './group-by/matcher.js'; +export type { GroupByConfig } from './group-by/types.js'; +export type { GroupRenderProps } from './group-by/types.js'; +export * from './selection.js'; +export * from './types.js'; diff --git a/packages/affine/microsheet-data-view/src/core/common/literal/define.ts b/packages/affine/microsheet-data-view/src/core/common/literal/define.ts new file mode 100644 index 000000000000..72e05215d3ff --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/literal/define.ts @@ -0,0 +1,186 @@ +import { + createPopup, + menu, + popMenu, +} from '@blocksuite/affine-components/context-menu'; +import { computed, signal } from '@preact/signals-core'; +import { html } from 'lit'; +import { styleMap } from 'lit/directives/style-map.js'; + +import type { LiteralData } from './types.js'; + +import { + tBoolean, + tDate, + tNumber, + tString, + tTag, +} from '../../logical/data-type.js'; +import { MatcherCreator } from '../../logical/matcher.js'; +import { isTArray, tArray } from '../../logical/typesystem.js'; +import { createUniComponentFromWebComponent } from '../../utils/uni-component/uni-component.js'; +import { DateLiteral } from './renderer/date-literal.js'; +import { + BooleanLiteral, + NumberLiteral, + StringLiteral, +} from './renderer/literal-element.js'; +import { MultiTagLiteral, TagLiteral } from './renderer/tag-literal.js'; + +const literalMatcherCreator = new MatcherCreator(); +export const literalMatchers = [ + literalMatcherCreator.createMatcher(tBoolean.create(), { + view: createUniComponentFromWebComponent(BooleanLiteral), + popEdit: (position, { value$, onChange }) => { + popMenu(position, { + options: { + items: [true, false].map(v => { + return menu.action({ + name: v.toString().toUpperCase(), + isSelected: v === value$.value, + select: () => { + onChange(v); + }, + }); + }), + }, + }); + }, + }), + literalMatcherCreator.createMatcher(tString.create(), { + view: createUniComponentFromWebComponent(StringLiteral), + popEdit: (position, { value$, onChange }) => { + popMenu(position, { + options: { + items: [ + menu.input({ + initialValue: value$.value?.toString() ?? '', + onComplete: text => { + onChange(text || undefined); + }, + }), + ], + }, + }); + }, + }), + literalMatcherCreator.createMatcher(tNumber.create(), { + view: createUniComponentFromWebComponent(NumberLiteral), + popEdit: (position, { value$, onChange }) => { + popMenu(position, { + options: { + items: [ + menu.input({ + initialValue: value$.value?.toString() ?? '', + onComplete: text => { + if (!text) { + onChange(undefined); + return; + } + const number = Number.parseFloat(text); + if (!Number.isNaN(number)) { + onChange(number); + } + }, + }), + ], + }, + }); + }, + }), + literalMatcherCreator.createMatcher(tArray(tTag.create()), { + view: createUniComponentFromWebComponent(MultiTagLiteral), + popEdit: (position, { value$, onChange, type }) => { + if (!isTArray(type)) { + return; + } + if (!tTag.is(type.ele)) { + return; + } + const list$ = signal(Array.isArray(value$.value) ? value$.value : []); + // const list$ = computed(()=>{ + // return Array.isArray(value$.value) ? value$.value : [] + // }); + popMenu(position, { + options: { + items: + type.ele.data?.tags.map(tag => { + const styles = styleMap({ + backgroundColor: tag.color, + padding: '0 8px', + width: 'max-content', + }); + return menu.checkbox({ + name: tag.value, + checked: computed(() => list$.value.includes(tag.id)), + label: () => + html`
+ ${tag.value} +
`, + select: checked => { + if (checked) { + list$.value = list$.value.filter(v => v !== tag.id); + onChange(list$.value); + return false; + } else { + list$.value = [...list$.value, tag.id]; + onChange(list$.value); + return true; + } + }, + }); + }) ?? [], + }, + }); + }, + }), + literalMatcherCreator.createMatcher(tTag.create(), { + view: createUniComponentFromWebComponent(TagLiteral), + popEdit: (position, { onChange, type }) => { + if (!tTag.is(type)) { + return; + } + popMenu(position, { + options: { + items: + type.data?.tags.map(tag => { + const styles = styleMap({ + backgroundColor: tag.color, + padding: '0 8px', + width: 'max-content', + }); + return menu.action({ + name: tag.value, + label: () => + html`
+ ${tag.value} +
`, + select: () => { + onChange(tag.id); + }, + }); + }) ?? [], + }, + }); + }, + }), + literalMatcherCreator.createMatcher(tDate.create(), { + view: createUniComponentFromWebComponent(DateLiteral), + popEdit: (position, { value$, onChange }) => { + const input = document.createElement('input'); + input.type = 'date'; + input.click(); + input.valueAsNumber = value$.value as number; + document.body.append(input); + input.style.position = 'absolute'; + const close = createPopup(position, input); + requestAnimationFrame(() => { + input.showPicker(); + input.onchange = () => { + onChange(input.valueAsNumber); + close(); + }; + }); + }, + }), +]; diff --git a/packages/affine/microsheet-data-view/src/core/common/literal/matcher.ts b/packages/affine/microsheet-data-view/src/core/common/literal/matcher.ts new file mode 100644 index 000000000000..817b68f3ca69 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/literal/matcher.ts @@ -0,0 +1,36 @@ +import type { PopupTarget } from '@blocksuite/affine-components/context-menu'; +import type { ReadonlySignal } from '@preact/signals-core'; + +import type { TType } from '../../logical/typesystem.js'; +import type { LiteralData } from './types.js'; + +import { Matcher } from '../../logical/matcher.js'; +import { renderUniLit } from '../../utils/uni-component/uni-component.js'; +import { literalMatchers } from './define.js'; + +export const renderLiteral = ( + type: TType, + value: ReadonlySignal, + onChange: (value: unknown) => void +) => { + const data = literalMatcher.match(type); + if (!data) { + return; + } + return renderUniLit(data.view, { value$: value, onChange, type }); +}; + +export const popLiteralEdit = ( + target: PopupTarget, + type: TType, + value: ReadonlySignal, + onChange: (value: unknown) => void +) => { + const data = literalMatcher.match(type); + if (!data) { + return; + } + data.popEdit(target, { value$: value, onChange, type: type }); +}; + +export const literalMatcher = new Matcher(literalMatchers); diff --git a/packages/affine/microsheet-data-view/src/core/common/literal/renderer/array-literal.ts b/packages/affine/microsheet-data-view/src/core/common/literal/renderer/array-literal.ts new file mode 100644 index 000000000000..0e8b2f718259 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/literal/renderer/array-literal.ts @@ -0,0 +1,11 @@ +import { html } from 'lit'; + +import type { TArray } from '../../../logical/typesystem.js'; + +import { LiteralElement } from './literal-element.js'; + +export class ArrayLiteral extends LiteralElement { + override render() { + return html``; + } +} diff --git a/packages/affine/microsheet-data-view/src/core/common/literal/renderer/date-literal.ts b/packages/affine/microsheet-data-view/src/core/common/literal/renderer/date-literal.ts new file mode 100644 index 000000000000..70ef2cbbf24c --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/literal/renderer/date-literal.ts @@ -0,0 +1,12 @@ +import { format } from 'date-fns/format'; +import { html } from 'lit'; + +import { LiteralElement } from './literal-element.js'; + +export class DateLiteral extends LiteralElement { + override render() { + return this.value$.value + ? format(new Date(this.value$.value), 'yyyy/MM/dd') + : html`Value`; + } +} diff --git a/packages/affine/microsheet-data-view/src/core/common/literal/renderer/literal-element.ts b/packages/affine/microsheet-data-view/src/core/common/literal/renderer/literal-element.ts new file mode 100644 index 000000000000..7e09ba823581 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/literal/renderer/literal-element.ts @@ -0,0 +1,67 @@ +import type { ReadonlySignal } from '@preact/signals-core'; + +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { css, html } from 'lit'; +import { property } from 'lit/decorators.js'; + +import type { TType } from '../../../logical/typesystem.js'; +import type { LiteralViewProps } from '../types.js'; + +export abstract class LiteralElement + extends SignalWatcher(WithDisposable(ShadowlessElement)) + implements LiteralViewProps +{ + @property({ attribute: false }) + accessor onChange!: (value?: T) => void; + + @property({ attribute: false }) + accessor type!: Type; + + @property({ attribute: false }) + accessor value$!: ReadonlySignal; +} + +export class BooleanLiteral extends LiteralElement { + override render() { + return this.value$.value ? 'True' : 'False'; + } +} + +export class NumberLiteral extends LiteralElement { + static override styles = css` + data-view-literal-number-view { + display: block; + max-width: 100px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + `; + + override render() { + return ( + this.value$.value?.toString() ?? + html`Value` + ); + } +} + +export class StringLiteral extends LiteralElement { + static override styles = css` + data-view-literal-string-view { + display: block; + max-width: 100px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + `; + + override render() { + return ( + this.value$.value?.toString() ?? + html`Value` + ); + } +} diff --git a/packages/affine/microsheet-data-view/src/core/common/literal/renderer/tag-literal.ts b/packages/affine/microsheet-data-view/src/core/common/literal/renderer/tag-literal.ts new file mode 100644 index 000000000000..6eabc41ea9b3 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/literal/renderer/tag-literal.ts @@ -0,0 +1,74 @@ +import { css, html } from 'lit'; + +import type { TArray, TypeOfData } from '../../../logical/typesystem.js'; + +import { tTag } from '../../../logical/data-type.js'; +import { LiteralElement } from './literal-element.js'; + +export class TagLiteral extends LiteralElement< + string, + TypeOfData +> { + static override styles = css` + data-view-literal-tag-view { + max-width: 100px; + display: block; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + `; + + override render() { + if (!this.value$.value) { + return html`Value`; + } + return ( + this.tags().find(v => v.id === this.value$.value)?.value ?? + html`Value` + ); + } + + tags() { + const tags = this.type.data?.tags; + if (!tags) { + return []; + } + return tags; + } +} + +export class MultiTagLiteral extends LiteralElement< + string[], + TArray> +> { + static override styles = css` + data-view-literal-multi-tag-view { + max-width: 100px; + display: block; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + `; + + override render() { + if (!this.value$.value?.length) { + return html`Value`; + } + const tagMap = new Map(this.tags().map(v => [v.id, v.value])); + return html`${this.value$.value.map(id => tagMap.get(id)).join(', ')}`; + } + + tags() { + const type = this.type.ele; + if (!tTag.is(type)) { + return []; + } + const tags = type.data?.tags; + if (!tags) { + return []; + } + return tags; + } +} diff --git a/packages/affine/microsheet-data-view/src/core/common/literal/renderer/union-string.ts b/packages/affine/microsheet-data-view/src/core/common/literal/renderer/union-string.ts new file mode 100644 index 000000000000..ab2cf3045006 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/literal/renderer/union-string.ts @@ -0,0 +1,11 @@ +import { html } from 'lit'; + +import type { TUnion } from '../../../logical/typesystem.js'; + +import { LiteralElement } from './literal-element.js'; + +export class TagLiteral extends LiteralElement { + override render() { + return html``; + } +} diff --git a/packages/affine/microsheet-data-view/src/core/common/literal/types.ts b/packages/affine/microsheet-data-view/src/core/common/literal/types.ts new file mode 100644 index 000000000000..70f0b5a4c9ab --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/literal/types.ts @@ -0,0 +1,15 @@ +import type { PopupTarget } from '@blocksuite/affine-components/context-menu'; +import type { ReadonlySignal } from '@preact/signals-core'; + +import type { TType } from '../../logical/index.js'; +import type { UniComponent } from '../../utils/index.js'; + +export type LiteralViewProps = { + type: Type; + value$: ReadonlySignal; + onChange: (value?: Value) => void; +}; +export type LiteralData = { + view: UniComponent>; + popEdit: (position: PopupTarget, props: LiteralViewProps) => void; +}; diff --git a/packages/affine/microsheet-data-view/src/core/common/popup.ts b/packages/affine/microsheet-data-view/src/core/common/popup.ts new file mode 100644 index 000000000000..9eb489eda324 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/popup.ts @@ -0,0 +1,31 @@ +import { autoPlacement, computePosition } from '@floating-ui/dom'; + +import { onClickOutside } from '../utils/utils.js'; + +export const createMicrosheetPopup = ( + target: HTMLElement, + content: HTMLElement, + options?: { + onClose?: () => void; + } +) => { + target.parentElement?.append(content); + computePosition(target, content, { + middleware: [autoPlacement()], + }) + .then(({ x, y }) => { + Object.assign(content.style, { + left: `${x}px`, + top: `${y}px`, + }); + }) + .catch(console.error); + onClickOutside( + content, + () => { + content.remove(); + options?.onClose?.(); + }, + 'mousedown' + ); +}; diff --git a/packages/affine/microsheet-data-view/src/core/common/properties.ts b/packages/affine/microsheet-data-view/src/core/common/properties.ts new file mode 100644 index 000000000000..50f248f43e4a --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/properties.ts @@ -0,0 +1,267 @@ +import { + menu, + popMenu, + type PopupTarget, +} from '@blocksuite/affine-components/context-menu'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { InvisibleIcon, ViewIcon } from '@blocksuite/icons/lit'; +import { css, html } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { repeat } from 'lit/directives/repeat.js'; +import Sortable from 'sortablejs'; + +import type { Property } from '../view-manager/property.js'; +import type { SingleView } from '../view-manager/single-view.js'; + +export class DataViewPropertiesSettingView extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = css` + .properties-group-header { + user-select: none; + padding: 4px 12px 12px 12px; + margin-bottom: 12px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--affine-divider-color); + } + + .properties-group-title { + font-size: 12px; + line-height: 20px; + color: var(--affine-text-secondary-color); + display: flex; + align-items: center; + gap: 8px; + } + + .properties-group-op { + padding: 4px 8px; + font-size: 12px; + line-height: 20px; + font-weight: 500; + border-radius: 4px; + cursor: pointer; + } + + .properties-group-op:hover { + background-color: var(--affine-hover-color); + } + + .properties-group { + min-height: 40px; + } + + .property-item { + padding: 4px; + display: flex; + align-items: center; + gap: 8px; + user-select: none; + cursor: pointer; + border-radius: 4px; + } + + .property-item:hover { + background-color: var(--affine-hover-color); + } + + .property-item-drag-bar { + width: 4px; + height: 12px; + border-radius: 1px; + background-color: #efeff0; + } + + .property-item:hover .property-item-drag-bar { + background-color: #c0bfc1; + } + + .property-item-icon { + display: flex; + align-items: center; + } + + .property-item-icon svg { + color: var(--affine-icon-color); + fill: var(--affine-icon-color); + width: 20px; + height: 20px; + } + + .property-item-op-icon { + display: flex; + align-items: center; + border-radius: 4px; + } + + .property-item-op-icon:hover { + background-color: var(--affine-hover-color); + } + .property-item-op-icon.disabled:hover { + background-color: transparent; + } + + .property-item-op-icon svg { + fill: var(--affine-icon-color); + color: var(--affine-icon-color); + width: 20px; + height: 20px; + } + + .property-item-op-icon.disabled svg { + fill: var(--affine-text-disable-color); + color: var(--affine-text-disable-color); + } + + .property-item-name { + font-size: 14px; + line-height: 22px; + flex: 1; + } + `; + + renderProperty = (property: Property) => { + const isTitle = property.type$.value === 'title'; + const icon = property.hide$.value ? InvisibleIcon() : ViewIcon(); + const changeVisible = () => { + if (property.type$.value !== 'title') { + property.hideSet(!property.hide$.value); + } + }; + const classList = classMap({ + 'property-item-op-icon': true, + disabled: isTitle, + }); + return html`
+
+ +
${property.name$.value}
+
${icon}
+
`; + }; + + private itemsGroup() { + return this.view.propertiesWithoutFilter$.value.map(id => + this.view.propertyGet(id) + ); + } + + override connectedCallback() { + super.connectedCallback(); + this._disposables.addFromEvent(this, 'pointerdown', e => { + e.stopPropagation(); + }); + } + + override firstUpdated() { + const sortable = new Sortable(this.groupContainer, { + animation: 150, + group: `properties-sort-${this.view.id}`, + onEnd: evt => { + const properties = [...this.view.propertiesWithoutFilter$.value]; + const index = evt.oldIndex ?? -1; + const from = properties[index]; + properties.splice(index, 1); + const to = properties[evt.newIndex ?? -1]; + this.view.propertyMove( + from, + to + ? { + before: true, + id: to, + } + : 'end' + ); + }, + }); + this._disposables.add({ + dispose: () => sortable.destroy(), + }); + } + + override render() { + const items = this.itemsGroup(); + return html` +
+ ${repeat(items, v => v.id, this.renderProperty)} +
+ `; + } + + @query('.properties-group') + accessor groupContainer!: HTMLElement; + + @property({ attribute: false }) + accessor onBack: (() => void) | undefined = undefined; + + @property({ attribute: false }) + accessor view!: SingleView; +} + +declare global { + interface HTMLElementTagNameMap { + 'data-view-properties-setting': DataViewPropertiesSettingView; + } +} + +export const popPropertiesSetting = ( + target: PopupTarget, + props: { + view: SingleView; + onClose?: () => void; + onBack?: () => void; + } +) => { + popMenu(target, { + options: { + title: { + text: 'Properties', + onBack: props.onBack, + postfix: () => { + const items = props.view.propertiesWithoutFilter$.value.map(id => + props.view.propertyGet(id) + ); + const isAllShowed = items.every(v => !v.hide$.value); + const clickChangeAll = () => { + props.view.propertiesWithoutFilter$.value.forEach(id => { + if (props.view.propertyTypeGet(id) !== 'title') { + props.view.propertyHideSet(id, isAllShowed); + } + }); + }; + return html`
+ ${isAllShowed ? 'Hide All' : 'Show All'} +
`; + }, + }, + items: [ + menu.group({ + items: [ + () => + html``, + ], + }), + ], + }, + }); + + // const view = new DataViewPropertiesSettingView(); + // view.view = props.view; + // view.onBack = () => { + // close(); + // props.onBack?.(); + // }; + // const close = createPopup(target, view, { onClose: props.onClose }); +}; diff --git a/packages/affine/microsheet-data-view/src/core/common/property-menu.ts b/packages/affine/microsheet-data-view/src/core/common/property-menu.ts new file mode 100644 index 000000000000..9f28317934f5 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/property-menu.ts @@ -0,0 +1,49 @@ +import { menu } from '@blocksuite/affine-components/context-menu'; +import { html } from 'lit/static-html.js'; + +import type { Property } from '../view-manager/property.js'; + +import { renderUniLit } from '../utils/uni-component/index.js'; + +export const inputConfig = (property: Property) => { + return menu.input({ + prefix: html` +
+ ${renderUniLit(property.icon)} +
+ `, + initialValue: property.name$.value, + onComplete: text => { + property.nameSet(text); + }, + }); +}; +export const typeConfig = (property: Property) => { + return menu.subMenu({ + name: 'Type', + hide: () => !property.typeSet || property.type$.value === 'title', + postfix: html`
+ ${renderUniLit(property.icon)} + ${property.view.propertyMetas.find(v => v.type === property.type$.value) + ?.config.name} +
`, + options: { + items: property.view.propertyMetas.map(config => { + return menu.action({ + isSelected: config.type === property.type$.value, + name: config.config.name, + prefix: renderUniLit(property.view.IconGet(config.type)), + select: () => { + if (property.type$.value === config.type) { + return; + } + property.typeSet?.(config.type); + }, + }); + }), + }, + }); +}; diff --git a/packages/affine/microsheet-data-view/src/core/common/ref/ref.ts b/packages/affine/microsheet-data-view/src/core/common/ref/ref.ts new file mode 100644 index 000000000000..31cf0ebda1e5 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/ref/ref.ts @@ -0,0 +1,161 @@ +import type { ReadonlySignal } from '@preact/signals-core'; + +import { + menu, + popFilterableSimpleMenu, + popMenu, + type PopupTarget, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { AddCursorIcon } from '@blocksuite/icons/lit'; +import { css, html } from 'lit'; +import { property } from 'lit/decorators.js'; + +import type { Filter, Variable, VariableOrProperty } from '../ast.js'; + +import { renderUniLit } from '../../utils/uni-component/uni-component.js'; +import { firstFilterByRef, firstFilterInGroup } from '../ast.js'; + +export class VariableRefView extends WithDisposable(ShadowlessElement) { + static override styles = css` + microsheet-variable-ref-view { + font-size: 12px; + line-height: 20px; + display: flex; + align-items: center; + gap: 6px; + padding: 0 4px; + border-radius: 4px; + cursor: pointer; + } + + microsheet-variable-ref-view:hover { + background-color: var(--affine-hover-color); + } + + microsheet-variable-ref-view svg { + width: 16px; + height: 16px; + fill: var(--affine-icon-color); + color: var(--affine-icon-color); + } + `; + + get field() { + if (!this.data) { + return; + } + if (this.data.type === 'ref') { + return this.data.name; + } + return this.data.ref.name; + } + + get fieldData() { + const id = this.field; + if (!id) { + return; + } + return this.vars.find(v => v.id === id); + } + + get property() { + if (!this.data) { + return; + } + if (this.data.type === 'ref') { + return; + } + return this.data.propertyFuncName; + } + + override connectedCallback() { + super.connectedCallback(); + this.disposables.addFromEvent(this, 'click', e => { + popFilterableSimpleMenu( + popupTargetFromElement(e.target as HTMLElement), + this.vars.map(v => + menu.action({ + name: v.name, + prefix: renderUniLit(v.icon, {}), + select: () => { + this.setData({ + type: 'ref', + name: v.id, + }); + }, + }) + ) + ); + }); + } + + override render() { + const data = this.fieldData; + return html` ${renderUniLit(data?.icon, {})} ${data?.name} `; + } + + @property({ attribute: false }) + accessor data: VariableOrProperty | undefined = undefined; + + @property({ attribute: false }) + accessor setData!: (filter: VariableOrProperty) => void; + + @property({ attribute: false }) + accessor vars!: Variable[]; +} + +declare global { + interface HTMLElementTagNameMap { + 'microsheet-variable-ref-view': VariableRefView; + } +} +export const popCreateFilter = ( + target: PopupTarget, + props: { + vars: ReadonlySignal; + onSelect: (filter: Filter) => void; + onClose?: () => void; + onBack?: () => void; + } +) => { + popMenu(target, { + options: { + onClose: props.onClose, + title: { + onBack: props.onBack, + text: 'New filter', + }, + items: [ + ...props.vars.value.map(v => + menu.action({ + name: v.name, + prefix: renderUniLit(v.icon, {}), + select: () => { + props.onSelect( + firstFilterByRef(props.vars.value, { + type: 'ref', + name: v.id, + }) + ); + }, + }) + ), + menu.group({ + name: '', + items: [ + menu.action({ + name: 'Add filter group', + prefix: AddCursorIcon(), + select: () => { + props.onSelect(firstFilterInGroup(props.vars.value)); + }, + }), + ], + }), + ], + }, + }); +}; diff --git a/packages/affine/microsheet-data-view/src/core/common/selection.ts b/packages/affine/microsheet-data-view/src/core/common/selection.ts new file mode 100644 index 000000000000..8b301040c88d --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/selection.ts @@ -0,0 +1,134 @@ +import { BaseSelection, SelectionExtension } from '@blocksuite/block-std'; +import { z } from 'zod'; + +import type { DataViewSelection, GetDataViewSelection } from '../types.js'; + +const TableViewSelectionSchema = z.union([ + z.object({ + viewId: z.string(), + type: z.literal('table'), + selectionType: z.literal('area'), + rowsSelection: z.object({ + start: z.number(), + end: z.number(), + }), + columnsSelection: z.object({ + start: z.number(), + end: z.number(), + }), + focus: z.object({ + rowIndex: z.number(), + columnIndex: z.number(), + }), + isEditing: z.boolean(), + }), + z.object({ + viewId: z.string(), + type: z.literal('table'), + selectionType: z.literal('row'), + rows: z.array( + z.object({ id: z.string(), groupKey: z.string().optional() }) + ), + }), +]); + +const KanbanCellSelectionSchema = z.object({ + selectionType: z.literal('cell'), + groupKey: z.string(), + cardId: z.string(), + columnId: z.string(), + isEditing: z.boolean(), +}); + +const KanbanCardSelectionSchema = z.object({ + selectionType: z.literal('card'), + cards: z.array( + z.object({ + groupKey: z.string(), + cardId: z.string(), + }) + ), +}); + +const KanbanGroupSelectionSchema = z.object({ + selectionType: z.literal('group'), + groupKeys: z.array(z.string()), +}); + +const MicrosheetSelectionSchema = z.object({ + blockId: z.string(), + viewSelection: z.union([ + TableViewSelectionSchema, + KanbanCellSelectionSchema, + KanbanCardSelectionSchema, + KanbanGroupSelectionSchema, + ]), +}); + +export class MicrosheetSelection extends BaseSelection { + static override group = 'note'; + + static override type = 'microsheet'; + + readonly viewSelection: DataViewSelection; + + get viewId() { + return this.viewSelection.viewId; + } + + constructor({ + blockId, + viewSelection, + }: { + blockId: string; + viewSelection: DataViewSelection; + }) { + super({ + blockId, + }); + + this.viewSelection = viewSelection; + } + + static override fromJSON(json: Record): MicrosheetSelection { + MicrosheetSelectionSchema.parse(json); + return new MicrosheetSelection({ + blockId: json.blockId as string, + viewSelection: json.viewSelection as DataViewSelection, + }); + } + + override equals(other: BaseSelection): boolean { + if (!(other instanceof MicrosheetSelection)) { + return false; + } + return this.blockId === other.blockId; + } + + getSelection( + type: T + ): GetDataViewSelection | undefined { + return this.viewSelection.type === type + ? (this.viewSelection as GetDataViewSelection) + : undefined; + } + + override toJSON(): Record { + return { + type: 'microsheet', + blockId: this.blockId, + viewSelection: this.viewSelection, + }; + } +} + +declare global { + namespace BlockSuite { + interface Selection { + microsheet: typeof MicrosheetSelection; + } + } +} + +export const MicrosheetSelectionExtension = + SelectionExtension(MicrosheetSelection); diff --git a/packages/affine/microsheet-data-view/src/core/common/stats/any.ts b/packages/affine/microsheet-data-view/src/core/common/stats/any.ts new file mode 100644 index 000000000000..12c8a26112b0 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/stats/any.ts @@ -0,0 +1,98 @@ +import type { StatsFunction } from './type.js'; + +import { tUnknown } from '../../logical/typesystem.js'; + +export const anyTypeStatsFunctions: StatsFunction[] = [ + { + group: 'Count', + menuName: 'Count All', + displayName: 'All', + type: 'count-all', + dataType: tUnknown.create(), + impl: (data: unknown[]) => { + return data.length.toString(); + }, + }, + { + group: 'Count', + menuName: 'Count Values', + displayName: 'Values', + type: 'count-values', + dataType: tUnknown.create(), + impl: (data: unknown[], { meta }) => { + const values = data + .flatMap(v => { + if (meta.config.values) { + return meta.config.values(v); + } + return v; + }) + .filter(v => v != null); + return values.length.toString(); + }, + }, + { + group: 'Count', + menuName: 'Count Unique Values', + displayName: 'Unique Values', + type: 'count-unique-values', + dataType: tUnknown.create(), + impl: (data: unknown[], { meta }) => { + const values = data + .flatMap(v => { + if (meta.config.values) { + return meta.config.values(v); + } + return v; + }) + .filter(v => v != null); + return new Set(values).size.toString(); + }, + }, + { + group: 'Count', + menuName: 'Count Empty', + displayName: 'Empty', + type: 'count-empty', + dataType: tUnknown.create(), + impl: (data, { meta }) => { + const emptyList = data.filter(value => meta.config.isEmpty(value)); + return emptyList.length.toString(); + }, + }, + { + group: 'Count', + menuName: 'Count Not Empty', + displayName: 'Not Empty', + type: 'count-not-empty', + dataType: tUnknown.create(), + impl: (data: unknown[], { meta }) => { + const notEmptyList = data.filter(value => !meta.config.isEmpty(value)); + return notEmptyList.length.toString(); + }, + }, + { + group: 'Percent', + menuName: 'Percent Empty', + displayName: 'Empty', + type: 'percent-empty', + dataType: tUnknown.create(), + impl: (data: unknown[], { meta }) => { + if (data.length === 0) return ''; + const emptyList = data.filter(value => meta.config.isEmpty(value)); + return ((emptyList.length / data.length) * 100).toFixed(2) + '%'; + }, + }, + { + group: 'Percent', + menuName: 'Percent Not Empty', + displayName: 'Not Empty', + type: 'percent-not-empty', + dataType: tUnknown.create(), + impl: (data: unknown[], { meta }) => { + if (data.length === 0) return ''; + const notEmptyList = data.filter(value => !meta.config.isEmpty(value)); + return ((notEmptyList.length / data.length) * 100).toFixed(2) + '%'; + }, + }, +]; diff --git a/packages/affine/microsheet-data-view/src/core/common/stats/checkbox.ts b/packages/affine/microsheet-data-view/src/core/common/stats/checkbox.ts new file mode 100644 index 000000000000..4058535a0b57 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/stats/checkbox.ts @@ -0,0 +1,62 @@ +import type { StatsFunction } from './type.js'; + +import { tBoolean } from '../../logical/index.js'; + +export const checkboxTypeStatsFunctions: StatsFunction[] = [ + { + group: 'Count', + type: 'count-values', + dataType: tBoolean.create(), + }, + { + group: 'Count', + type: 'count-unique-values', + dataType: tBoolean.create(), + }, + { + group: 'Count', + type: 'count-empty', + dataType: tBoolean.create(), + menuName: 'Count Unchecked', + displayName: 'Unchecked', + impl: data => { + const emptyList = data.filter(value => !value); + return emptyList.length.toString(); + }, + }, + { + group: 'Count', + type: 'count-not-empty', + dataType: tBoolean.create(), + menuName: 'Count Checked', + displayName: 'Checked', + impl: (data: unknown[]) => { + const notEmptyList = data.filter(value => !!value); + return notEmptyList.length.toString(); + }, + }, + { + group: 'Percent', + type: 'percent-empty', + dataType: tBoolean.create(), + menuName: 'Percent Unchecked', + displayName: 'Unchecked', + impl: (data: unknown[]) => { + if (data.length === 0) return ''; + const emptyList = data.filter(value => !value); + return ((emptyList.length / data.length) * 100).toFixed(2) + '%'; + }, + }, + { + group: 'Percent', + type: 'percent-not-empty', + dataType: tBoolean.create(), + menuName: 'Percent Checked', + displayName: 'Checked', + impl: (data: unknown[]) => { + if (data.length === 0) return ''; + const notEmptyList = data.filter(value => !!value); + return ((notEmptyList.length / data.length) * 100).toFixed(2) + '%'; + }, + }, +]; diff --git a/packages/affine/microsheet-data-view/src/core/common/stats/index.ts b/packages/affine/microsheet-data-view/src/core/common/stats/index.ts new file mode 100644 index 000000000000..4a214f2cb8a0 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/stats/index.ts @@ -0,0 +1,11 @@ +import type { StatsFunction } from './type.js'; + +import { anyTypeStatsFunctions } from './any.js'; +import { checkboxTypeStatsFunctions } from './checkbox.js'; +import { numberStatsFunctions } from './number.js'; + +export const statsFunctions: StatsFunction[] = [ + ...anyTypeStatsFunctions, + ...numberStatsFunctions, + ...checkboxTypeStatsFunctions, +]; diff --git a/packages/affine/microsheet-data-view/src/core/common/stats/number.ts b/packages/affine/microsheet-data-view/src/core/common/stats/number.ts new file mode 100644 index 000000000000..1432803f8241 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/stats/number.ts @@ -0,0 +1,116 @@ +import type { StatsFunction } from './type.js'; + +import { tNumber } from '../../logical/data-type.js'; + +export const numberStatsFunctions: StatsFunction[] = [ + { + group: 'More options', + menuName: 'Sum', + type: 'sum', + dataType: tNumber.create(), + impl: (data: number[]) => { + const numbers = withoutNull(data); + if (numbers.length === 0) { + return 'None'; + } + return numbers.reduce((a, b) => a + b, 0).toString(); + }, + }, + { + group: 'More options', + menuName: 'Average', + type: 'average', + dataType: tNumber.create(), + impl: (data: number[]) => { + const numbers = withoutNull(data); + if (numbers.length === 0) { + return 'None'; + } + return (numbers.reduce((a, b) => a + b, 0) / numbers.length).toString(); + }, + }, + { + group: 'More options', + menuName: 'Median', + type: 'median', + dataType: tNumber.create(), + impl: (data: number[]) => { + const arr = withoutNull(data).sort((a, b) => a - b); + let result = 0; + if (arr.length % 2 === 1) { + result = arr[(arr.length - 1) / 2]; + } else { + const index = arr.length / 2; + result = (arr[index] + arr[index - 1]) / 2; + } + return result?.toString() ?? 'None'; + }, + }, + { + group: 'More options', + menuName: 'Min', + type: 'min', + dataType: tNumber.create(), + impl: (data: number[]) => { + let min: number | null = null; + for (const num of data) { + if (num != null) { + if (min == null) { + min = num; + } else { + min = Math.min(min, num); + } + } + } + return min?.toString() ?? 'None'; + }, + }, + { + group: 'More options', + menuName: 'Max', + type: 'max', + dataType: tNumber.create(), + impl: (data: number[]) => { + let max: number | null = null; + for (const num of data) { + if (num != null) { + if (max == null) { + max = num; + } else { + max = Math.max(max, num); + } + } + } + return max?.toString() ?? 'None'; + }, + }, + { + group: 'More options', + menuName: 'Range', + type: 'range', + dataType: tNumber.create(), + impl: (data: number[]) => { + let min: number | null = null; + let max: number | null = null; + for (const num of data) { + if (num != null) { + if (max == null) { + max = num; + } else { + max = Math.max(max, num); + } + if (min == null) { + min = num; + } else { + min = Math.min(min, num); + } + } + } + if (min == null || max == null) { + return 'None'; + } + return (max - min).toString(); + }, + }, +]; +const withoutNull = (arr: number[]) => arr.filter(v => v != null); diff --git a/packages/affine/microsheet-data-view/src/core/common/stats/type.ts b/packages/affine/microsheet-data-view/src/core/common/stats/type.ts new file mode 100644 index 000000000000..b58a19b114cb --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/stats/type.ts @@ -0,0 +1,17 @@ +import type { TType } from '../../logical/typesystem.js'; +import type { PropertyMetaConfig } from '../../property/property-config.js'; + +export type StatsFunction = { + group: string; + type: string; + dataType: TType; + menuName?: string; + displayName?: string; + impl?: ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: any[], + info: { + meta: PropertyMetaConfig; + } + ) => string; +}; diff --git a/packages/affine/microsheet-data-view/src/core/common/types.ts b/packages/affine/microsheet-data-view/src/core/common/types.ts new file mode 100644 index 000000000000..46369e4fa89f --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/types.ts @@ -0,0 +1,23 @@ +import type { VariableOrProperty } from './ast.js'; + +export type GroupBy = { + type: 'groupBy'; + columnId: string; + name: string; + sort?: { + desc: boolean; + }; +}; +export type GroupProperty = { + key: string; + hide?: boolean; + manuallyCardSort: string[]; +}; +export type SortBy = { + ref: VariableOrProperty; + desc: boolean; +}; +export type Sort = { + sortBy: SortBy[]; + manuallySort: string[]; +}; diff --git a/packages/affine/microsheet-data-view/src/core/data-view.ts b/packages/affine/microsheet-data-view/src/core/data-view.ts new file mode 100644 index 000000000000..25591480f409 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/data-view.ts @@ -0,0 +1,221 @@ +import type { BlockStdScope } from '@blocksuite/block-std'; + +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { computed, type ReadonlySignal } from '@preact/signals-core'; +import { css, unsafeCSS } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { keyed } from 'lit/directives/keyed.js'; +import { createRef, ref } from 'lit/directives/ref.js'; +import { html } from 'lit/static-html.js'; + +import type { DataSource } from './common/data-source/base.js'; +import type { DataViewSelection, DataViewSelectionState } from './types.js'; +import type { DataViewExpose, DataViewProps } from './view/types.js'; +import type { SingleView } from './view-manager/single-view.js'; + +import { dataViewCommonStyle } from './common/css-variable.js'; +import { renderUniLit } from './utils/uni-component/index.js'; + +type ViewProps = { + view: SingleView; + selection$: ReadonlySignal; + setSelection: (selection?: DataViewSelectionState) => void; + bindHotkey: DataViewProps['bindHotkey']; + handleEvent: DataViewProps['handleEvent']; +}; + +export type DataViewRendererConfig = { + bindHotkey: DataViewProps['bindHotkey']; + handleEvent: DataViewProps['handleEvent']; + virtualPadding$: DataViewProps['virtualPadding$']; + selection$: ReadonlySignal; + setSelection: (selection: DataViewSelection | undefined) => void; + dataSource: DataSource; + detailPanelConfig: { + openDetailPanel: ( + target: HTMLElement, + data: { + view: SingleView; + rowId: string; + } + ) => Promise; + }; + headerWidget: DataViewProps['headerWidget']; + onDrag?: DataViewProps['onDrag']; + std: BlockStdScope; +}; + +export class DataViewRenderer extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = css` + ${unsafeCSS(dataViewCommonStyle('affine-microsheet-data-view-renderer'))} + affine-microsheet-data-view-renderer { + background-color: var(--affine-background-primary-color); + display: contents; + } + `; + + private _view = createRef<{ expose: DataViewExpose }>(); + + @property({ attribute: false }) + accessor config!: DataViewRendererConfig; + + private currentViewId$ = computed(() => { + return this.config.dataSource.viewManager.currentViewId$.value; + }); + + viewMap$ = computed(() => { + const manager = this.config.dataSource.viewManager; + console.log( + 11112222222, + Object.fromEntries( + manager.views$.value.map(view => [view, manager.viewGet(view)]) + ) + ); + return Object.fromEntries( + manager.views$.value.map(view => [view, manager.viewGet(view)]) + ); + }); + + currentViewConfig$ = computed(() => { + const currentViewId = this.currentViewId$.value; + if (!currentViewId) { + return; + } + const view = this.viewMap$.value[currentViewId]; + return { + view: view, + selection$: computed(() => { + const selection$ = this.config.selection$; + if (selection$.value?.viewId === currentViewId) { + return selection$.value; + } + return; + }), + setSelection: selection => { + this.config.setSelection(selection); + }, + handleEvent: (name, handler) => + this.config.handleEvent(name, context => { + return handler(context); + }), + bindHotkey: hotkeys => + this.config.bindHotkey( + Object.fromEntries( + Object.entries(hotkeys).map(([key, fn]) => [ + key, + ctx => { + return fn(ctx); + }, + ]) + ) + ), + }; + }); + + focusFirstCell = () => { + this.view?.expose.focusFirstCell(); + }; + + openDetailPanel = (ops: { + view: SingleView; + rowId: string; + onClose?: () => void; + }) => { + const openDetailPanel = this.config.detailPanelConfig.openDetailPanel; + if (openDetailPanel) { + openDetailPanel(this, { + view: ops.view, + rowId: ops.rowId, + }) + .catch(console.error) + .finally(ops.onClose); + } + }; + + get view() { + return this._view.value; + } + + private renderView(viewData?: ViewProps) { + if (!viewData) { + return; + } + const props: DataViewProps = { + dataViewEle: this, + headerWidget: this.config.headerWidget, + view: viewData.view, + selection$: viewData.selection$, + setSelection: viewData.setSelection, + bindHotkey: viewData.bindHotkey, + handleEvent: viewData.handleEvent, + onDrag: this.config.onDrag, + std: this.config.std, + dataSource: this.config.dataSource, + virtualPadding$: this.config.virtualPadding$, + }; + return keyed( + viewData.view.id, + renderUniLit( + viewData.view.meta.renderer.view, + { props }, + { + ref: this._view, + } + ) + ); + } + + override connectedCallback() { + super.connectedCallback(); + let preId: string | undefined = undefined; + this.disposables.add( + this.currentViewId$.subscribe(current => { + if (current !== preId) { + this.config.setSelection(undefined); + } + preId = current; + }) + ); + } + + override render() { + const containerClass = classMap({ + 'toolbar-hover-container': true, + 'data-view-root': true, + 'prevent-reference-popup': true, + }); + return html` +
+ ${this.renderView(this.currentViewConfig$.value)} +
+ `; + } + + @state() + accessor currentView: string | undefined = undefined; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-microsheet-data-view-renderer': DataViewRenderer; + } +} + +export class DataView { + private _ref = createRef(); + + get expose() { + return this._ref.value?.view?.expose; + } + + render(props: DataViewRendererConfig) { + return html``; + } +} diff --git a/packages/affine/microsheet-data-view/src/core/index.ts b/packages/affine/microsheet-data-view/src/core/index.ts new file mode 100644 index 000000000000..1bd6c4c36c75 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/index.ts @@ -0,0 +1,11 @@ +export { DataSourceBase } from './common/data-source/base.js'; +export * from './common/index.js'; +export { DataView } from './data-view.js'; +export * from './logical/index.js'; +export * from './property/index.js'; +export type { DataViewSelection } from './types.js'; +export * from './types.js'; +export * from './utils/index.js'; +export * from './view/index.js'; +export * from './view-manager/index.js'; +export * from './widget/index.js'; diff --git a/packages/affine/microsheet-data-view/src/core/logical/data-type.ts b/packages/affine/microsheet-data-view/src/core/logical/data-type.ts new file mode 100644 index 000000000000..2b29eb997bda --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/logical/data-type.ts @@ -0,0 +1,44 @@ +import type { SelectTag } from '../utils/tags/multi-tag-select.js'; + +import { typesystem } from './typesystem.js'; + +export const tNumber = typesystem.defineData<{ value: number }>({ + name: 'Number', + supers: [], +}); +export const tString = typesystem.defineData<{ value: string }>({ + name: 'String', + supers: [], +}); +export const tRichText = typesystem.defineData<{ value: string }>({ + name: 'RichText', + supers: [tString], +}); +export const tBoolean = typesystem.defineData<{ value: boolean }>({ + name: 'Boolean', + supers: [], +}); +export const tDate = typesystem.defineData<{ value: number }>({ + name: 'Date', + supers: [], +}); +export const tURL = typesystem.defineData({ + name: 'URL', + supers: [tString], +}); +export const tImage = typesystem.defineData({ + name: 'Image', + supers: [], +}); +export const tEmail = typesystem.defineData({ + name: 'Email', + supers: [tString], +}); +export const tPhone = typesystem.defineData({ + name: 'Phone', + supers: [tString], +}); +export const tTag = typesystem.defineData<{ tags: SelectTag[] }>({ + name: 'Tag', + supers: [], +}); diff --git a/packages/affine/microsheet-data-view/src/core/logical/eval-filter.ts b/packages/affine/microsheet-data-view/src/core/logical/eval-filter.ts new file mode 100644 index 000000000000..be1b9410086e --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/logical/eval-filter.ts @@ -0,0 +1,64 @@ +import type { Filter, Value, VariableOrProperty } from '../common/ast.js'; + +import { filterMatcher } from '../../widget-presets/filter/matcher/matcher.js'; +import { propertyMatcher } from './property-matcher.js'; + +const evalRef = ( + ref: VariableOrProperty, + row: Record +): unknown => { + if (ref.type === 'ref') { + return row[ref.name]; + } + const value = evalRef(ref.ref, row); + const fn = propertyMatcher.findData(v => v.name === ref.propertyFuncName); + try { + return fn?.impl(value); + } catch (e) { + console.error(e); + return; + } +}; + +const evalValue = (value: Value, _row: Record): unknown => { + return value.value; + // TODO + // switch (value.type) { + // case "ref": + // return evalRef(value, row) + // case "literal": + // return value.value; + // } +}; +export const evalFilter = ( + filterGroup: Filter, + row: Record +): boolean => { + const evalF = (filter: Filter): boolean => { + if (filter.type === 'filter') { + const value = evalRef(filter.left, row); + const func = filterMatcher.findData(v => v.name === filter.function); + const args = filter.args.map(value => evalValue(value, row)); + try { + if ((func?.impl.length ?? 0) > args.length + 1) { + // skip + return true; + } + const impl = func?.impl(value, ...args); + return impl ?? true; + } catch (e) { + console.error(e); + return true; + } + } else if (filter.type === 'group') { + if (filter.op === 'and') { + return filter.conditions.every(f => evalF(f)); + } else if (filter.op === 'or') { + return filter.conditions.some(f => evalF(f)); + } + } + return true; + }; + // console.log(evalF(filterGroup)) + return evalF(filterGroup); +}; diff --git a/packages/affine/microsheet-data-view/src/core/logical/index.ts b/packages/affine/microsheet-data-view/src/core/logical/index.ts new file mode 100644 index 000000000000..02659e1e1bc2 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/logical/index.ts @@ -0,0 +1,2 @@ +export * from './data-type.js'; +export * from './typesystem.js'; diff --git a/packages/affine/microsheet-data-view/src/core/logical/matcher.ts b/packages/affine/microsheet-data-view/src/core/logical/matcher.ts new file mode 100644 index 000000000000..72feed1c3041 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/logical/matcher.ts @@ -0,0 +1,71 @@ +import type { TType } from './typesystem.js'; + +import { typesystem } from './typesystem.js'; + +type MatcherData = { + type: Type; + data: Data; +}; + +export class MatcherCreator { + createMatcher(type: Type, data: Data) { + return { type, data }; + } +} + +export class Matcher { + constructor( + private list: MatcherData[], + private _match: ( + type: Type, + target: TType + ) => boolean = typesystem.isSubtype.bind(typesystem) + ) {} + + all(): MatcherData[] { + return this.list; + } + + allMatched(type: TType): MatcherData[] { + const result: MatcherData[] = []; + for (const t of this.list) { + if (this._match(t.type, type)) { + result.push(t); + } + } + return result; + } + + allMatchedData(type: TType): Data[] { + const result: Data[] = []; + for (const t of this.list) { + if (this._match(t.type, type)) { + result.push(t.data); + } + } + return result; + } + + find( + f: (data: MatcherData) => boolean + ): MatcherData | undefined { + return this.list.find(f); + } + + findData(f: (data: Data) => boolean): Data | undefined { + return this.list.find(data => f(data.data))?.data; + } + + isMatched(type: Type, target: TType) { + return this._match(type, target); + } + + match(type: TType) { + for (const t of this.list) { + if (this._match(t.type, type)) { + return t.data; + } + } + return; + } +} diff --git a/packages/affine/microsheet-data-view/src/core/logical/property-matcher.ts b/packages/affine/microsheet-data-view/src/core/logical/property-matcher.ts new file mode 100644 index 000000000000..74face0f78ce --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/logical/property-matcher.ts @@ -0,0 +1,102 @@ +import type { TFunction } from './typesystem.js'; + +import { tDate, tNumber, tString } from './data-type.js'; +import { Matcher, MatcherCreator } from './matcher.js'; +import { tArray, tFunction, tUnknown, typesystem } from './typesystem.js'; + +type PropertyData = { + name: string; + impl: (...args: unknown[]) => unknown; +}; +const propertyMatcherCreator = new MatcherCreator(); +const propertyMatchers = [ + propertyMatcherCreator.createMatcher( + tFunction({ + args: [tString.create()], + rt: tNumber.create(), + }), + { + name: 'Length', + impl: value => { + if (typeof value !== 'string') { + return 0; + } + return value.length; + }, + } + ), + propertyMatcherCreator.createMatcher( + tFunction({ + args: [tDate.create()], + rt: tNumber.create(), + }), + { + name: 'Day of month', + impl: value => { + if (typeof value !== 'number') { + return 0; + } + return new Date(value).getDate(); + }, + } + ), + propertyMatcherCreator.createMatcher( + tFunction({ + args: [tDate.create()], + rt: tNumber.create(), + }), + { + name: 'Day of week', + impl: value => { + if (typeof value !== 'number') { + return 0; + } + return new Date(value).getDay(); + }, + } + ), + propertyMatcherCreator.createMatcher( + tFunction({ + args: [tDate.create()], + rt: tNumber.create(), + }), + { + name: 'Month of year', + impl: value => { + if (typeof value !== 'number') { + return 0; + } + return new Date(value).getMonth() + 1; + }, + } + ), + propertyMatcherCreator.createMatcher( + tFunction({ + args: [tArray(tUnknown.create())], + rt: tNumber.create(), + }), + { + name: 'Size', + impl: value => { + if (!Array.isArray(value)) { + return 0; + } + return value.length; + }, + } + ), +]; +export const propertyMatcher = new Matcher( + propertyMatchers, + (type, target) => { + if (type.type !== 'function') { + return false; + } + const staticType = typesystem.subst( + Object.fromEntries(type.typeVars?.map(v => [v.name, v.bound]) ?? []), + type + ); + const firstArg = staticType.args[0]; + return firstArg && typesystem.isSubtype(firstArg, target); + } +); diff --git a/packages/affine/microsheet-data-view/src/core/logical/typesystem.ts b/packages/affine/microsheet-data-view/src/core/logical/typesystem.ts new file mode 100644 index 000000000000..e67498d2e1ef --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/logical/typesystem.ts @@ -0,0 +1,292 @@ +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; + +export interface TUnion { + type: 'union'; + title: 'union'; + list: TType[]; +} + +export const tUnion = (list: TType[]): TUnion => ({ + type: 'union', + title: 'union', + list, +}); + +// TODO treat as data type +export interface TArray<_Ele extends TType = TType> { + type: 'array'; + ele: TType; + title: 'array'; +} + +export const tArray = (ele: T): TArray => { + return { + type: 'array', + title: 'array', + ele, + }; +}; +export const isTArray = (type: TType): type is TArray => { + return type.type === 'array'; +}; +export type TTypeVar = { + type: 'typeVar'; + title: 'typeVar'; + name: string; + bound: TType; +}; +export const tTypeVar = (name: string, bound: TType): TTypeVar => { + return { + type: 'typeVar', + title: 'typeVar', + name, + bound, + }; +}; +export type TTypeRef = { + type: 'typeRef'; + title: 'typeRef'; + name: string; +}; +export const tTypeRef = (name: string): TTypeRef => { + return { + type: 'typeRef', + title: 'typeRef', + name, + }; +}; + +export type TFunction = { + type: 'function'; + title: 'function'; + typeVars: TTypeVar[]; + args: TType[]; + rt: TType; +}; + +export const tFunction = (fn: { + typeVars?: TTypeVar[]; + args: TType[]; + rt: TType; +}): TFunction => { + return { + type: 'function', + title: 'function', + typeVars: fn.typeVars ?? [], + args: fn.args, + rt: fn.rt, + }; +}; + +export type TType = TDataType | TArray | TUnion | TTypeRef | TFunction; + +export type DataTypeShape = Record; +export type TDataType> = { + type: 'data'; + name: string; + data?: Data; +}; +export type ValueOfData = + T extends DataDefine ? R : never; +export type TypeOfData = + T extends DataDefine ? TDataType : never; + +export class DataDefine> { + constructor( + private config: DataDefineConfig, + private dataMap: Map + ) {} + + private isByName(name: string): boolean { + return name === this.config.name; + } + + private isSubOfByName(superType: string): boolean { + if (this.isByName(superType)) { + return true; + } + return this.config.supers.some(sup => sup.isSubOfByName(superType)); + } + + create(data?: Data): TDataType { + return { + type: 'data', + name: this.config.name, + data, + }; + } + + is(data: TType): data is TDataType { + if (data.type !== 'data') { + return false; + } + return data.name === this.config.name; + } + + isSubOf(superType: TDataType): boolean { + if (this.is(superType)) { + return true; + } + return this.config.supers.some(sup => sup.isSubOf(superType)); + } + + isSuperOf(subType: TDataType): boolean { + const dataDefine = this.dataMap.get(subType.name); + if (!dataDefine) { + throw new BlockSuiteError( + ErrorCode.MicrosheetBlockError, + 'data config not found' + ); + } + return dataDefine.isSubOfByName(this.config.name); + } +} + +// type DataTypeVar = {}; + +// TODO support generic data type +interface DataDefineConfig<_T extends DataTypeShape> { + name: string; + supers: DataDefine[]; +} + +interface DataHelper { + create>(name: string): DataDefineConfig; + + extends( + dataDefine: DataDefine + ): DataHelper; +} + +const createDataHelper = >( + ...supers: DataDefine[] +): DataHelper => { + return { + create(name: string) { + return { + name, + supers, + }; + }, + extends(dataDefine) { + return createDataHelper(...supers, dataDefine); + }, + }; +}; +const DataHelper = createDataHelper(); + +export class Typesystem { + dataMap = new Map(); + + defineData( + config: DataDefineConfig + ): DataDefine { + const result = new DataDefine(config, this.dataMap); + this.dataMap.set(config.name, result); + return result; + } + + instance( + context: Record, + realArgs: TType[], + realRt: TType, + template: TFunction + ): TFunction { + const ctx = { ...context }; + template.args.forEach((arg, i) => { + const realArg = realArgs[i]; + if (realArg) { + this.isSubtype(arg, realArg, ctx); + } + }); + this.isSubtype(realRt, template.rt); + return this.subst(ctx, template); + } + + isDataType(t: TType): t is TDataType { + return t.type === 'data'; + } + + isSubtype( + superType: TType, + sub: TType, + context?: Record + ): boolean { + if (superType.type === 'typeRef') { + // TODO both are ref + if (context && sub.type != 'typeRef') { + context[superType.name] = sub; + } + // TODO bound + return true; + } + if (sub.type === 'typeRef') { + // TODO both are ref + if (context) { + context[sub.name] = superType; + } + return true; + } + if (tUnknown.is(superType)) { + return true; + } + if (superType.type === 'union') { + return superType.list.some(type => this.isSubtype(type, sub, context)); + } + if (sub.type === 'union') { + return sub.list.every(type => this.isSubtype(superType, type, context)); + } + + if (this.isDataType(sub)) { + const dataDefine = this.dataMap.get(sub.name); + if (!dataDefine) { + throw new BlockSuiteError( + ErrorCode.MicrosheetBlockError, + 'data config not found' + ); + } + if (!this.isDataType(superType)) { + return false; + } + return dataDefine.isSubOf(superType); + } + + if (superType.type === 'array' || sub.type === 'array') { + if (superType.type !== 'array' || sub.type !== 'array') { + return false; + } + return this.isSubtype(superType.ele, sub.ele, context); + } + return false; + } + + subst(context: Record, template: TFunction): TFunction { + const subst = (type: TType): TType => { + if (this.isDataType(type)) { + return type; + } + switch (type.type) { + case 'typeRef': + return { ...context[type.name] }; + case 'union': + return tUnion(type.list.map(type => subst(type))); + case 'array': + return tArray(subst(type.ele)); + case 'function': + throw new BlockSuiteError( + ErrorCode.MicrosheetBlockError, + 'not implement yet' + ); + } + }; + const result = tFunction({ + args: template.args.map(type => subst(type)), + rt: subst(template.rt), + }); + return result; + } +} + +export const typesystem = new Typesystem(); + +export const tUnknown = typesystem.defineData(DataHelper.create('Unknown')); diff --git a/packages/affine/microsheet-data-view/src/core/property/base-cell.ts b/packages/affine/microsheet-data-view/src/core/property/base-cell.ts new file mode 100644 index 000000000000..054d8bbcebd3 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/property/base-cell.ts @@ -0,0 +1,114 @@ +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { computed } from '@preact/signals-core'; +import { property } from 'lit/decorators.js'; + +import type { Cell } from '../view-manager/cell.js'; +import type { CellRenderProps, DataViewCellLifeCycle } from './manager.js'; + +export abstract class BaseCellRenderer< + Value, + Data extends Record = Record, + > + extends SignalWatcher(WithDisposable(ShadowlessElement)) + implements DataViewCellLifeCycle, CellRenderProps +{ + @property({ attribute: false }) + accessor cell!: Cell; + + readonly$ = computed(() => { + return this.cell.property.readonly$.value; + }); + + value$ = computed(() => { + return this.cell.value$.value; + }); + + get property() { + return this.cell.property; + } + + get readonly() { + return this.readonly$.value; + } + + get row() { + return this.cell.row; + } + + get value() { + return this.value$.value; + } + + get view() { + return this.cell.view; + } + + beforeEnterEditMode(): boolean { + return true; + } + + blurCell() { + return true; + } + + override connectedCallback() { + super.connectedCallback(); + this.style.width = '100%'; + this._disposables.addFromEvent(this, 'click', e => { + if (this.isEditing) { + e.stopPropagation(); + } + }); + + this._disposables.addFromEvent(this, 'copy', e => { + if (!this.isEditing) return; + e.stopPropagation(); + this.onCopy(e); + }); + + this._disposables.addFromEvent(this, 'cut', e => { + if (!this.isEditing) return; + e.stopPropagation(); + this.onCut(e); + }); + + this._disposables.addFromEvent(this, 'paste', e => { + if (!this.isEditing) return; + e.stopPropagation(); + this.onPaste(e); + }); + } + + focusCell() { + return true; + } + + forceUpdate(): void { + this.requestUpdate(); + } + + onChange(value: Value | undefined): void { + this.cell.valueSet(value); + } + + onCopy(_e: ClipboardEvent) {} + + onCut(_e: ClipboardEvent) {} + + onEnterEditMode(): void { + // do nothing + } + + onExitEditMode() { + // do nothing + } + + onPaste(_e: ClipboardEvent) {} + + @property({ attribute: false }) + accessor isEditing!: boolean; + + @property({ attribute: false }) + accessor selectCurrentCell!: (editing: boolean) => void; +} diff --git a/packages/affine/microsheet-data-view/src/core/property/convert.ts b/packages/affine/microsheet-data-view/src/core/property/convert.ts new file mode 100644 index 000000000000..d5f6a5607588 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/property/convert.ts @@ -0,0 +1,32 @@ +import type { PropertyModel } from './property-config.js'; +import type { + GetCellDataFromConfig, + GetPropertyDataFromConfig, +} from './types.js'; + +export type ConvertFunction< + From extends PropertyModel = PropertyModel, + To extends PropertyModel = PropertyModel, +> = ( + property: GetPropertyDataFromConfig, + cells: (GetCellDataFromConfig | undefined)[] +) => { + property: GetPropertyDataFromConfig; + cells: (GetCellDataFromConfig | undefined)[]; +}; +export const createPropertyConvert = < + // eslint-disable-next-line @typescript-eslint/no-explicit-any + From extends PropertyModel, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + To extends PropertyModel, +>( + from: From, + to: To, + convert: ConvertFunction +) => { + return { + from: from.type, + to: to.type, + convert, + }; +}; diff --git a/packages/affine/microsheet-data-view/src/core/property/index.ts b/packages/affine/microsheet-data-view/src/core/property/index.ts new file mode 100644 index 000000000000..5ca2e9268347 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/property/index.ts @@ -0,0 +1,6 @@ +export * from './base-cell.js'; +export * from './convert.js'; +export * from './manager.js'; +export * from './property-config.js'; +export * from './renderer.js'; +export * from './types.js'; diff --git a/packages/affine/microsheet-data-view/src/core/property/manager.ts b/packages/affine/microsheet-data-view/src/core/property/manager.ts new file mode 100644 index 000000000000..1e27a5818f1d --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/property/manager.ts @@ -0,0 +1,38 @@ +import type { UniComponent } from '../utils/uni-component/index.js'; +import type { Cell } from '../view-manager/cell.js'; + +export interface CellRenderProps< + Data extends NonNullable = NonNullable, + Value = unknown, +> { + cell: Cell; + isEditing: boolean; + selectCurrentCell: (editing: boolean) => void; +} + +export interface DataViewCellLifeCycle { + beforeEnterEditMode(): boolean; + + onEnterEditMode(): void; + + onExitEditMode(): void; + + focusCell(): boolean; + + blurCell(): boolean; + + forceUpdate(): void; +} + +export type DataViewCellComponent< + Data extends NonNullable = NonNullable, + Value = unknown, +> = UniComponent, DataViewCellLifeCycle>; + +export type CellRenderer< + Data extends NonNullable = NonNullable, + Value = unknown, +> = { + view: DataViewCellComponent; + edit?: DataViewCellComponent; +}; diff --git a/packages/affine/microsheet-data-view/src/core/property/property-config.ts b/packages/affine/microsheet-data-view/src/core/property/property-config.ts new file mode 100644 index 000000000000..540cfaa7ff2f --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/property/property-config.ts @@ -0,0 +1,72 @@ +import type { Renderer } from './renderer.js'; +import type { PropertyConfig } from './types.js'; + +export type PropertyMetaConfig< + Type extends string = string, + PropertyData extends NonNullable = NonNullable, + CellData = unknown, +> = { + type: Type; + config: PropertyConfig; + create: Create; + renderer: Renderer; +}; +type CreatePropertyMeta< + Type extends string = string, + PropertyData extends Record = Record, + CellData = unknown, +> = ( + renderer: Omit, 'type'> +) => PropertyMetaConfig; +type Create< + PropertyData extends Record = Record, +> = ( + name: string, + data?: PropertyData +) => { + type: string; + name: string; + statCalcOp?: string; + data: PropertyData; +}; +export type PropertyModel< + Type extends string = string, + PropertyData extends Record = Record, + CellData = unknown, +> = { + type: Type; + config: PropertyConfig; + create: Create; + createPropertyMeta: CreatePropertyMeta; +}; +export const propertyType = (type: Type) => ({ + type: type, + modelConfig: < + CellData, + PropertyData extends Record = Record, + >( + ops: PropertyConfig + ): PropertyModel => { + const create: Create = (name, data) => { + return { + type, + name, + data: data ?? ops.defaultData(), + }; + }; + return { + type, + config: ops, + create, + createPropertyMeta: renderer => ({ + type, + config: ops, + create, + renderer: { + type, + ...renderer, + }, + }), + }; + }, +}); diff --git a/packages/affine/microsheet-data-view/src/core/property/renderer.ts b/packages/affine/microsheet-data-view/src/core/property/renderer.ts new file mode 100644 index 000000000000..2670219cc158 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/property/renderer.ts @@ -0,0 +1,25 @@ +import type { BaseCellRenderer } from './base-cell.js'; +import type { CellRenderer, DataViewCellComponent } from './manager.js'; + +import { + createUniComponentFromWebComponent, + type UniComponent, +} from '../utils/uni-component/index.js'; + +export interface Renderer< + Data extends NonNullable = NonNullable, + Value = unknown, +> { + type: string; + icon?: UniComponent; + cellRenderer: CellRenderer; +} + +export const createFromBaseCellRenderer = < + Value, + Data extends Record = Record, +>( + renderer: new () => BaseCellRenderer +): DataViewCellComponent => { + return createUniComponentFromWebComponent(renderer as never) as never; +}; diff --git a/packages/affine/microsheet-data-view/src/core/property/types.ts b/packages/affine/microsheet-data-view/src/core/property/types.ts new file mode 100644 index 000000000000..376a4633e8a8 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/property/types.ts @@ -0,0 +1,43 @@ +import type { Disposable } from '@blocksuite/global/utils'; + +import type { TType } from '../logical/index.js'; + +export type GetPropertyDataFromConfig = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + T extends PropertyConfig ? R : never; +export type GetCellDataFromConfig = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + T extends PropertyConfig ? R : never; +export type PropertyConfig< + Data extends NonNullable = NonNullable, + Value = unknown, +> = { + name: string; + defaultData: () => Data; + type: (data: Data) => TType; + formatValue?: (value: unknown, colData: Data) => Value; + isEmpty: (value?: Value) => boolean; + values?: (value?: Value) => unknown[]; + cellToString: (data: Value, colData: Data) => string; + cellFromString: ( + data: string, + colData: Data + ) => { + value: unknown; + data?: Record; + }; + cellToJson: (data: Value, colData: Data) => DVJSON; + addGroup?: (text: string, oldData: Data) => Data; + onUpdate?: (value: Value, Data: Data, callback: () => void) => Disposable; + valueUpdate?: (value: Value, Data: Data, newValue: Value) => Value; +}; + +export type DVJSON = + | null + | number + | string + | boolean + | DVJSON[] + | { + [k: string]: DVJSON; + }; diff --git a/packages/affine/microsheet-data-view/src/core/types.ts b/packages/affine/microsheet-data-view/src/core/types.ts new file mode 100644 index 000000000000..ce38e5ddaf1b --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/types.ts @@ -0,0 +1,26 @@ +import type { KanbanViewSelectionWithType } from '../view-presets/kanban/types.js'; +import type { TableViewSelectionWithType } from '../view-presets/table/types.js'; + +export type DataViewSelection = + | TableViewSelectionWithType + | KanbanViewSelectionWithType; +export type GetDataViewSelection< + K extends DataViewSelection['type'], + T = DataViewSelection, +> = T extends { + type: K; +} + ? T + : never; +export type DataViewSelectionState = DataViewSelection | undefined; +export type PropertyDataUpdater< + Data extends Record = Record, +> = (data: Data) => Partial; + +export interface MicrosheetFlags { + enable_number_formatting: boolean; +} + +export const defaultMicrosheetFlags: Readonly = { + enable_number_formatting: false, +}; diff --git a/packages/affine/microsheet-data-view/src/core/utils/drag.ts b/packages/affine/microsheet-data-view/src/core/utils/drag.ts new file mode 100644 index 000000000000..e670893c98cd --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/utils/drag.ts @@ -0,0 +1,54 @@ +export const startDrag = < + T extends Record | void, + P = { + x: number; + }, +>( + evt: MouseEvent, + ops: { + transform?: (evt: MouseEvent) => P; + onDrag: (p: P) => T; + onMove: (p: P) => T; + onDrop: (result: T) => void; + onClear: () => void; + } +) => { + const transform = ops?.transform ?? (e => e as P); + const param = transform(evt); + const result = { + data: ops.onDrag(param), + last: param, + move: (p: P) => { + result.data = ops.onMove(p); + }, + }; + const clear = () => { + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + window.removeEventListener('keydown', keydown); + ops.onClear(); + }; + const keydown = (evt: KeyboardEvent) => { + if (evt.key === 'Escape') { + clear(); + } + }; + const move = (evt: PointerEvent) => { + evt.preventDefault(); + const p = transform(evt); + result.last = p; + result.data = ops.onMove(p); + }; + const up = () => { + try { + ops.onDrop(result.data); + } finally { + clear(); + } + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + window.addEventListener('keydown', keydown); + + return result; +}; diff --git a/packages/affine/microsheet-data-view/src/core/utils/event.ts b/packages/affine/microsheet-data-view/src/core/utils/event.ts new file mode 100644 index 000000000000..ab7001f33733 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/utils/event.ts @@ -0,0 +1,3 @@ +export function stopPropagation(event: Event) { + event.stopPropagation(); +} diff --git a/packages/affine/microsheet-data-view/src/core/utils/frame-loop.ts b/packages/affine/microsheet-data-view/src/core/utils/frame-loop.ts new file mode 100644 index 000000000000..7b55d959503f --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/utils/frame-loop.ts @@ -0,0 +1,93 @@ +export const startFrameLoop = (fn: (delta: number) => void) => { + let handle = 0; + let preTime = 0; + const run = () => { + handle = requestAnimationFrame(time => { + try { + fn(time - preTime); + } finally { + preTime = time; + run(); + } + }); + }; + run(); + return () => { + cancelAnimationFrame(handle); + }; +}; +const timeWeight = 1 / 16; +const distanceWeight = 1 / 8; +export const autoScrollOnBoundary = ( + container: HTMLElement, + ops?: { + vertical?: boolean; + horizontal?: boolean; + boundary?: + | number + | { + left?: number; + right?: number; + top?: number; + bottom?: number; + }; + onScroll?: () => void; + } +) => { + const { vertical = false, horizontal = true, boundary } = ops ?? {}; + const defaultBoundary = 20; + const { + left = defaultBoundary, + right = defaultBoundary, + top = defaultBoundary, + bottom = defaultBoundary, + } = typeof boundary === 'number' + ? { + left: boundary, + right: boundary, + top: boundary, + bottom: boundary, + } + : (boundary ?? { + left: defaultBoundary, + right: defaultBoundary, + top: defaultBoundary, + bottom: defaultBoundary, + }); + const mousePosition = { x: 0, y: 0 }; + const mouseMove = (e: MouseEvent) => { + mousePosition.x = e.clientX; + mousePosition.y = e.clientY; + }; + document.addEventListener('mousemove', mouseMove); + const scroll = (delta: number) => { + const rect = container.getBoundingClientRect(); + const { x, y } = mousePosition; + const getResult = (diff: number) => + (diff * distanceWeight + 1) * delta * timeWeight; + if (horizontal) { + const leftBound = rect.left + left; + const rightBound = rect.right - right; + if (x < leftBound) { + container.scrollLeft -= getResult(leftBound - x); + } else if (x > rightBound) { + container.scrollLeft += getResult(x - rightBound); + } + } + if (vertical) { + const topBound = rect.top + top; + const bottomBound = rect.bottom - bottom; + if (y < topBound) { + container.scrollTop -= getResult(topBound - x); + } else if (y > bottomBound) { + container.scrollTop += getResult(x - bottomBound); + } + } + ops?.onScroll?.(); + }; + const cancel = startFrameLoop(scroll); + return () => { + cancel(); + document.removeEventListener('mousemove', mouseMove); + }; +}; diff --git a/packages/affine/microsheet-data-view/src/core/utils/index.ts b/packages/affine/microsheet-data-view/src/core/utils/index.ts new file mode 100644 index 000000000000..37057f593dbd --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/utils/index.ts @@ -0,0 +1,3 @@ +export * from './tags/index.js'; +export * from './uni-component/index.js'; +export * from './uni-icon.js'; diff --git a/packages/affine/microsheet-data-view/src/core/utils/menu-title.ts b/packages/affine/microsheet-data-view/src/core/utils/menu-title.ts new file mode 100644 index 000000000000..10c118723721 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/utils/menu-title.ts @@ -0,0 +1,23 @@ +import { ArrowLeftBigIcon } from '@blocksuite/icons/lit'; +import { html } from 'lit'; + +export const menuTitle = (name: string, onBack: () => void) => { + return html` +
+
+ ${ArrowLeftBigIcon()} +
+
+ ${name} +
+
+ `; +}; diff --git a/packages/affine/microsheet-data-view/src/core/utils/tags/colors.ts b/packages/affine/microsheet-data-view/src/core/utils/tags/colors.ts new file mode 100644 index 000000000000..2f407ae88d7c --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/utils/tags/colors.ts @@ -0,0 +1,64 @@ +export type SelectOptionColor = { + color: string; + name: string; +}; + +export const selectOptionColors: SelectOptionColor[] = [ + { + color: 'var(--affine-tag-red)', + name: 'Red', + }, + { + color: 'var(--affine-tag-pink)', + name: 'Pink', + }, + { + color: 'var(--affine-tag-orange)', + name: 'Orange', + }, + { + color: 'var(--affine-tag-yellow)', + name: 'Yellow', + }, + { + color: 'var(--affine-tag-green)', + name: 'Green', + }, + { + color: 'var(--affine-tag-teal)', + name: 'Teal', + }, + { + color: 'var(--affine-tag-blue)', + name: 'Blue', + }, + { + color: 'var(--affine-tag-purple)', + name: 'Purple', + }, + { + color: 'var(--affine-tag-gray)', + name: 'Gray', + }, + { + color: 'var(--affine-tag-white)', + name: 'White', + }, +]; + +/** select tag color poll */ +const selectTagColorPoll = selectOptionColors.map(color => color.color); + +function tagColorHelper() { + let colors = [...selectTagColorPoll]; + return () => { + if (colors.length === 0) { + colors = [...selectTagColorPoll]; + } + const index = Math.floor(Math.random() * colors.length); + const color = colors.splice(index, 1)[0]; + return color; + }; +} + +export const getTagColor = tagColorHelper(); diff --git a/packages/affine/microsheet-data-view/src/core/utils/tags/index.ts b/packages/affine/microsheet-data-view/src/core/utils/tags/index.ts new file mode 100644 index 000000000000..acb9a8bbf2c9 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/utils/tags/index.ts @@ -0,0 +1,3 @@ +export * from './colors.js'; +export * from './multi-tag-select.js'; +export * from './multi-tag-view.js'; diff --git a/packages/affine/microsheet-data-view/src/core/utils/tags/multi-tag-select.ts b/packages/affine/microsheet-data-view/src/core/utils/tags/multi-tag-select.ts new file mode 100644 index 000000000000..98654c6a8474 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/utils/tags/multi-tag-select.ts @@ -0,0 +1,503 @@ +import { + createPopup, + menu, + popMenu, + type PopupTarget, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { rangeWrap } from '@blocksuite/affine-shared/utils'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { + CloseIcon, + DeleteIcon, + MoreHorizontalIcon, + PlusIcon, +} from '@blocksuite/icons/lit'; +import { nanoid } from '@blocksuite/store'; +import { flip, offset } from '@floating-ui/dom'; +import { property, query, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { html } from 'lit/static-html.js'; + +import { stopPropagation } from '../event.js'; +import { getTagColor, selectOptionColors } from './colors.js'; +import { styles } from './styles.js'; + +export type SelectTag = { + id: string; + color: string; + value: string; + parentId?: string; +}; + +type RenderOption = { + value: string; + id: string; + color: string; + isCreate: boolean; + group: SelectTag[]; + select: () => void; +}; + +export class MultiTagSelect extends WithDisposable(ShadowlessElement) { + static override styles = styles; + + private _clickItemOption = (e: MouseEvent, id: string) => { + e.stopPropagation(); + const option = this.options.find(v => v.id === id); + if (!option) { + return; + } + popMenu(popupTargetFromElement(e.currentTarget as HTMLElement), { + options: { + items: [ + menu.input({ + initialValue: option.value, + onComplete: text => { + this.changeTag({ + ...option, + value: text, + }); + }, + }), + menu.action({ + name: 'Delete', + prefix: DeleteIcon(), + class: 'delete-item', + select: () => { + this.deleteTag(id); + }, + }), + menu.group({ + name: 'color', + items: selectOptionColors.map(item => { + const styles = styleMap({ + backgroundColor: item.color, + borderRadius: '50%', + width: '20px', + height: '20px', + }); + return menu.action({ + name: item.name, + prefix: html`
`, + isSelected: option.color === item.color, + select: () => { + this.changeTag({ + ...option, + color: item.color, + }); + }, + }); + }), + }), + ], + }, + }); + }; + + private _createOption = () => { + const value = this.text.trim(); + if (value === '') return; + const groupInfo = this.getGroupInfoByFullName(value); + if (!groupInfo) { + return; + } + const name = groupInfo.name; + const tagColor = this.color; + this.clearColor(); + const newSelect: SelectTag = { + id: nanoid(), + value: name, + color: tagColor, + parentId: groupInfo.parent?.id, + }; + this.newTags([newSelect]); + const newValue = this.isSingleMode + ? [newSelect.id] + : [...this.value, newSelect.id]; + this.onChange(newValue); + this.text = ''; + if (this.isSingleMode) { + this.editComplete(); + } + }; + + private _currentColor: string | undefined = undefined; + + private _onDeleteSelected = (selectedValue: string[], value: string) => { + const filteredValue = selectedValue.filter(item => item !== value); + this.onChange(filteredValue); + }; + + private _onInput = (event: KeyboardEvent) => { + this.text = (event.target as HTMLInputElement).value; + }; + + private _onInputKeydown = (event: KeyboardEvent) => { + event.stopPropagation(); + const inputValue = this.text.trim(); + if (event.key === 'Backspace' && inputValue === '') { + this._onDeleteSelected(this.value, this.value[this.value.length - 1]); + } else if (event.key === 'Enter' && !event.isComposing) { + this.selectedTag?.select(); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + this.setSelectedOption(this.selectedIndex - 1); + } else if (event.key === 'ArrowDown') { + event.preventDefault(); + this.setSelectedOption(this.selectedIndex + 1); + } else if (event.key === 'Escape') { + this.editComplete(); + } else if (event.key === 'Tab') { + event.preventDefault(); + const selectTag = this.selectedTag; + if (selectTag) { + this.text = this.getTagFullName(selectTag, selectTag.group); + } + } + }; + + private _onSelect = (id: string) => { + const isExist = this.value.some(item => item === id); + if (isExist) { + // this.editComplete(); + return; + } + + const isSelected = this.value.indexOf(id) > -1; + if (!isSelected) { + const newValue = this.isSingleMode ? [id] : [...this.value, id]; + this.onChange(newValue); + if (this.isSingleMode) { + setTimeout(() => { + this.editComplete(); + }, 4); + } + } + this.text = ''; + }; + + private filteredOptions: Array = []; + + changeTag = (tag: SelectTag) => { + this.onOptionsChange(this.options.map(v => (v.id === tag.id ? tag : v))); + }; + + deleteTag = (id: string) => { + this.onOptionsChange( + this.options + .filter(v => v.id !== id) + .map(v => ({ + ...v, + parentId: v.parentId === id ? undefined : v.parentId, + })) + ); + }; + + newTags = (tags: SelectTag[]) => { + this.onOptionsChange([...tags, ...this.options]); + }; + + private get color() { + if (!this._currentColor) { + this._currentColor = getTagColor(); + } + return this._currentColor; + } + + get isSingleMode() { + return this.mode === 'single'; + } + + private get selectedTag() { + return this.filteredOptions[this.selectedIndex]; + } + + private _filterOptions() { + const map = this.optionsIdMap(); + let matched = false; + const options: RenderOption[] = this.options + .map(v => ({ + ...v, + group: this.getTagGroup(v, map), + })) + .filter(item => { + if (!this.text) { + return true; + } + return this.getTagFullName(item, item.group) + .toLocaleLowerCase() + .includes(this.text.toLocaleLowerCase()); + }) + .map(v => { + const fullName = this.getTagFullName(v, v.group); + if (fullName === this.text) { + matched = true; + } + return { + ...v, + isCreate: false, + select: () => this._onSelect(v.id), + }; + }); + if (this.text && !matched) { + options.push({ + id: 'create', + color: this.color, + value: this.text, + isCreate: true, + group: [], + select: this._createOption, + }); + } + return options; + } + + private clearColor() { + this._currentColor = undefined; + } + + private getGroupInfoByFullName(name: string) { + const strings = name.split('/'); + const names = strings.slice(0, -1); + const result: SelectTag[] = []; + for (const text of names) { + const parent = result[result.length - 1]; + const tag = this.options.find( + v => v.parentId === parent?.id && v.value === text + ); + if (!tag) { + return; + } + result.push(tag); + } + return { + name: strings[strings.length - 1], + group: result, + parent: result[result.length - 1], + }; + } + + private getTagFullName(tag: SelectTag, group: SelectTag[]) { + return [...group, tag].map(v => v.value).join('/'); + } + + private getTagGroup(tag: SelectTag, map = this.optionsIdMap()): SelectTag[] { + const result = []; + let parentId = tag.parentId; + while (parentId) { + const parent = map[parentId]; + result.unshift(parent); + parentId = parent.parentId; + } + return result; + } + + private optionsIdMap() { + return Object.fromEntries(this.options.map(v => [v.id, v])); + } + + private setSelectedOption(index: number) { + this.selectedIndex = rangeWrap(index, 0, this.filteredOptions.length); + } + + protected override firstUpdated() { + requestAnimationFrame(() => { + this._selectInput.focus(); + }); + this._disposables.addFromEvent(this, 'click', () => { + this._selectInput.focus(); + }); + + this._disposables.addFromEvent(this._selectInput, 'copy', e => { + e.stopPropagation(); + }); + this._disposables.addFromEvent(this._selectInput, 'cut', e => { + e.stopPropagation(); + }); + } + + override render() { + this.filteredOptions = this._filterOptions(); + this.setSelectedOption(this.selectedIndex); + const selectedTag = this.value; + const map = new Map(this.options.map(v => [v.id, v])); + return html` +
+
+ ${selectedTag.map(id => { + const option = map.get(id); + if (!option) { + return; + } + const style = styleMap({ + backgroundColor: option.color, + }); + return html`
+
${option.value}
+ ${CloseIcon()} +
`; + })} + +
+
+
+ Select tag or create one +
+ ${repeat( + this.filteredOptions, + select => select.id, + (select, index) => { + const isSelected = index === this.selectedIndex; + const mouseenter = () => { + this.setSelectedOption(index); + }; + const classes = classMap({ + 'select-option': true, + selected: isSelected, + }); + const style = styleMap({ + backgroundColor: select.color, + }); + const clickOption = (e: MouseEvent) => + this._clickItemOption(e, select.id); + return html` +
+
+ ${select.isCreate + ? html`
+ Create ${PlusIcon()} +
` + : ''} +
+
+ ${select.group.map((v, i) => { + const style = styleMap({ + backgroundColor: v.color, + }); + return html`${i === 0 + ? '' + : html`/`}${v.value}`; + })} +
+
+
+ ${select.value} +
+
+
+
+ ${!select.isCreate + ? html`
+ ${MoreHorizontalIcon()} +
` + : null} +
+ `; + } + )} +
+
+ `; + } + + @query('.select-input') + private accessor _selectInput!: HTMLInputElement; + + @property({ attribute: false }) + accessor editComplete!: () => void; + + @property() + accessor mode: 'multi' | 'single' = 'multi'; + + @property({ attribute: false }) + accessor onChange!: (value: string[]) => void; + + @property({ attribute: false }) + accessor onOptionsChange!: (options: SelectTag[]) => void; + + @property({ attribute: false }) + accessor options: SelectTag[] = []; + + @state() + private accessor selectedIndex = 0; + + @state() + private accessor text = ''; + + @property({ attribute: false }) + accessor value: string[] = []; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-microsheet-multi-tag-select': MultiTagSelect; + } +} + +export const popTagSelect = ( + target: PopupTarget, + ops: { + mode?: 'single' | 'multi'; + value: string[]; + onChange: (value: string[]) => void; + options: SelectTag[]; + onOptionsChange: (options: SelectTag[]) => void; + onComplete?: () => void; + minWidth?: number; + container?: HTMLElement; + } +) => { + const component = new MultiTagSelect(); + if (ops.mode) { + component.mode = ops.mode; + } + const width = target.targetRect.getBoundingClientRect().width; + component.style.width = `${Math.max(ops.minWidth ?? width, width)}px`; + component.value = ops.value; + component.onChange = tags => { + ops.onChange(tags); + component.value = tags; + }; + component.options = ops.options; + component.onOptionsChange = options => { + ops.onOptionsChange(options); + component.options = options; + }; + component.editComplete = () => { + ops.onComplete?.(); + remove(); + }; + const remove = createPopup(target, component, { + onClose: ops.onComplete, + middleware: [flip(), offset({ mainAxis: -28, crossAxis: 112 })], + container: ops.container, + }); + return remove; +}; diff --git a/packages/affine/microsheet-data-view/src/core/utils/tags/multi-tag-view.ts b/packages/affine/microsheet-data-view/src/core/utils/tags/multi-tag-view.ts new file mode 100644 index 000000000000..5ebf37e38977 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/utils/tags/multi-tag-view.ts @@ -0,0 +1,82 @@ +import { ShadowlessElement } from '@blocksuite/block-std'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { css } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { html } from 'lit/static-html.js'; + +import type { SelectTag } from './multi-tag-select.js'; + +export class MultiTagView extends WithDisposable(ShadowlessElement) { + static override styles = css` + affine-microsheet-multi-tag-view { + display: flex; + align-items: center; + width: 100%; + height: 100%; + min-height: 22px; + } + + .affine-select-cell-container * { + box-sizing: border-box; + } + + .affine-select-cell-container { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + width: 100%; + font-size: var(--affine-font-sm); + } + + .affine-select-cell-container .select-selected { + height: 22px; + font-size: 14px; + line-height: 22px; + padding: 0 8px; + border-radius: 4px; + white-space: nowrap; + background: var(--affine-tag-white); + overflow: hidden; + text-overflow: ellipsis; + } + `; + + override render() { + const values = this.value; + const map = new Map(this.options?.map(v => [v.id, v])); + return html` +
+ ${repeat(values, id => { + const option = map.get(id); + if (!option) { + return; + } + const style = styleMap({ + backgroundColor: option.color, + }); + return html`${option.value}`; + })} +
+ `; + } + + @property({ attribute: false }) + accessor options: SelectTag[] = []; + + @query('.affine-select-cell-container') + accessor selectContainer!: HTMLElement; + + @property({ attribute: false }) + accessor value: string[] = []; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-microsheet-multi-tag-view': MultiTagView; + } +} diff --git a/packages/affine/microsheet-data-view/src/core/utils/tags/styles.ts b/packages/affine/microsheet-data-view/src/core/utils/tags/styles.ts new file mode 100644 index 000000000000..334936d9bf26 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/utils/tags/styles.ts @@ -0,0 +1,200 @@ +import { baseTheme } from '@toeverything/theme'; +import { css, unsafeCSS } from 'lit'; + +export const styles = css` + affine-microsheet-multi-tag-select { + position: absolute; + z-index: 2; + border: 1px solid var(--affine-border-color); + border-radius: 8px; + background: var(--affine-background-primary-color); + box-shadow: var(--affine-shadow-2); + font-family: var(--affine-font-family); + min-width: 300px; + max-width: 720px; + } + + .affine-select-cell-select { + font-size: var(--affine-font-sm); + } + + @media print { + .affine-select-cell-select { + display: none; + } + } + + .select-input-container { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + min-height: 44px; + padding: 10px 8px; + background: var(--affine-hover-color); + border-radius: 8px; + } + + .select-input { + flex: 1 1 0; + height: 24px; + border: none; + font-family: ${unsafeCSS(baseTheme.fontSansFamily)}; + color: inherit; + background: transparent; + line-height: 24px; + } + + .select-input:focus { + outline: none; + } + + .select-input::placeholder { + color: var(--affine-placeholder-color); + } + + .select-option-container { + padding: 8px; + color: var(--affine-black-90); + fill: var(--affine-black-90); + max-height: 400px; + overflow-y: auto; + } + + .select-option-container-header { + padding: 0px 4px 8px 4px; + color: var(--affine-black-60); + font-size: 12px; + user-select: none; + } + + .select-input-container .select-selected { + display: flex; + align-items: center; + padding: 2px 10px; + gap: 10px; + height: 28px; + background: var(--affine-tag-white); + border-radius: 4px; + color: var(--affine-black-90); + background: var(--affine-tertiary-color); + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } + + .select-selected-text { + width: calc(100% - 16px); + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } + + .select-selected > .close-icon { + display: flex; + align-items: center; + } + + .select-selected > .close-icon:hover { + cursor: pointer; + } + + .select-selected > .close-icon > svg { + fill: var(--affine-black-90); + } + + .select-option-new { + display: flex; + flex-direction: row; + align-items: center; + height: 36px; + padding: 4px; + gap: 5px; + border-radius: 4px; + background: var(--affine-selected-color); + } + + .select-option-new-text { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + height: 28px; + padding: 2px 10px; + border-radius: 4px; + background: var(--affine-tag-red); + } + + .select-option-new-icon { + display: flex; + align-items: center; + gap: 6px; + height: 28px; + color: var(--affine-text-primary-color); + margin-right: 8px; + } + + .select-option-new-icon svg { + width: 16px; + height: 16px; + } + + .select-option { + position: relative; + display: flex; + justify-content: space-between; + align-items: center; + padding: 4px; + border-radius: 4px; + margin-bottom: 4px; + cursor: pointer; + } + + .select-option.selected { + background: var(--affine-hover-color); + } + + .select-option-text-container { + width: 100%; + overflow: hidden; + display: flex; + } + + .select-option-group-name { + font-size: 9px; + padding: 0 2px; + border-radius: 2px; + } + + .select-option-name { + padding: 4px 8px; + border-radius: 4px; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } + + .select-option-icon { + display: flex; + justify-content: center; + align-items: center; + width: 28px; + height: 28px; + border-radius: 3px; + cursor: pointer; + opacity: 0; + } + + .select-option.selected .select-option-icon { + opacity: 1; + } + + .select-option-icon:hover { + background: var(--affine-hover-color); + } + + .select-option-icon svg { + width: 16px; + height: 16px; + pointer-events: none; + } +`; diff --git a/packages/affine/microsheet-data-view/src/core/utils/uni-component/index.ts b/packages/affine/microsheet-data-view/src/core/utils/uni-component/index.ts new file mode 100644 index 000000000000..f035644582cf --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/utils/uni-component/index.ts @@ -0,0 +1,2 @@ +export * from './operation.js'; +export * from './uni-component.js'; diff --git a/packages/affine/microsheet-data-view/src/core/utils/uni-component/operation.ts b/packages/affine/microsheet-data-view/src/core/utils/uni-component/operation.ts new file mode 100644 index 000000000000..559ba1fbdb7b --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/utils/uni-component/operation.ts @@ -0,0 +1,17 @@ +import type { UniComponent } from './uni-component.js'; + +export const uniMap = >( + component: UniComponent, + map: (r: R) => T +): UniComponent => { + return (ele, props) => { + const result = component(ele, map(props)); + return { + unmount: result.unmount, + update: props => { + result.update(map(props)); + }, + expose: result.expose, + }; + }; +}; diff --git a/packages/affine/microsheet-data-view/src/core/utils/uni-component/render-template.ts b/packages/affine/microsheet-data-view/src/core/utils/uni-component/render-template.ts new file mode 100644 index 000000000000..f57d227506d0 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/utils/uni-component/render-template.ts @@ -0,0 +1,25 @@ +import type { TemplateResult } from 'lit'; + +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher } from '@blocksuite/global/utils'; +import { property } from 'lit/decorators.js'; + +export class AnyRender extends SignalWatcher(ShadowlessElement) { + override render() { + return this.renderTemplate(this.props); + } + + @property({ attribute: false }) + accessor props!: T; + + @property({ attribute: false }) + accessor renderTemplate!: (props: T) => TemplateResult | symbol; +} + +export const renderTemplate = ( + renderTemplate: (props: T) => TemplateResult | symbol +) => { + const ins = new AnyRender(); + ins.renderTemplate = renderTemplate; + return ins; +}; diff --git a/packages/affine/microsheet-data-view/src/core/utils/uni-component/uni-component.ts b/packages/affine/microsheet-data-view/src/core/utils/uni-component/uni-component.ts new file mode 100644 index 000000000000..c50eac5a723a --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/utils/uni-component/uni-component.ts @@ -0,0 +1,161 @@ +import type { LitElement, PropertyValues, TemplateResult } from 'lit'; +import type { Ref } from 'lit/directives/ref.js'; + +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher } from '@blocksuite/global/utils'; +import { css, html } from 'lit'; +import { property } from 'lit/decorators.js'; +import { type StyleInfo, styleMap } from 'lit/directives/style-map.js'; + +export type UniComponentReturn< + Props = NonNullable, + Expose extends NonNullable = NonNullable, +> = { + update: (props: Props) => void; + unmount: () => void; + expose: Expose; +}; +export type UniComponent< + Props = NonNullable, + Expose extends NonNullable = NonNullable, +> = (ele: HTMLElement, props: Props) => UniComponentReturn; +export const renderUniLit = >( + uni: UniComponent | undefined, + props?: Props, + options?: { + ref?: Ref; + style?: Readonly; + class?: string; + } +): TemplateResult => { + return html` `; +}; + +export class UniLit< + Props, + Expose extends NonNullable = NonNullable, +> extends ShadowlessElement { + static override styles = css` + microsheet-uni-lit { + display: contents; + } + `; + + uniReturn?: UniComponentReturn; + + get expose(): Expose | undefined { + return this.uniReturn?.expose; + } + + private mount() { + this.uniReturn = this.uni?.(this, this.props); + if (this.ref) { + // @ts-expect-error + this.ref.value = this.uniReturn?.expose; + } + } + + private unmount() { + this.uniReturn?.unmount(); + } + + override connectedCallback() { + super.connectedCallback(); + this.mount(); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this.unmount(); + } + + protected override render(): unknown { + return html``; + } + + protected override updated(_changedProperties: PropertyValues) { + super.updated(_changedProperties); + if (_changedProperties.has('uni')) { + this.unmount(); + this.mount(); + } else if (_changedProperties.has('props')) { + this.uniReturn?.update(this.props); + } + } + + @property({ attribute: false }) + accessor props!: Props; + + @property({ attribute: false }) + accessor ref: Ref | undefined = undefined; + + @property({ attribute: false }) + accessor uni: UniComponent | undefined = undefined; +} + +export const createUniComponentFromWebComponent = < + T, + Expose extends NonNullable = NonNullable, +>( + component: typeof LitElement +): UniComponent => { + return (ele, props) => { + const ins = new component(); + Object.assign(ins, props); + ele.append(ins); + return { + update: props => { + Object.assign(ins, props); + ins.requestUpdate(); + }, + unmount: () => { + ins.remove(); + }, + expose: ins as never as Expose, + }; + }; +}; + +export class UniAnyRender< + T, + Expose extends NonNullable, +> extends SignalWatcher(ShadowlessElement) { + override render() { + return this.renderTemplate(this.props, this.expose); + } + + @property({ attribute: false }) + accessor expose!: Expose; + + @property({ attribute: false }) + accessor props!: T; + + @property({ attribute: false }) + accessor renderTemplate!: (props: T, expose: Expose) => TemplateResult; +} +export const defineUniComponent = >( + renderTemplate: (props: T, expose: Expose) => TemplateResult +): UniComponent => { + return (ele, props) => { + const ins = new UniAnyRender(); + ins.props = props; + ins.expose = {} as Expose; + ins.renderTemplate = renderTemplate; + ele.append(ins); + return { + update: props => { + ins.props = props; + ins.requestUpdate(); + }, + unmount: () => { + ins.remove(); + }, + expose: ins.expose, + }; + }; +}; diff --git a/packages/affine/microsheet-data-view/src/core/utils/uni-icon.ts b/packages/affine/microsheet-data-view/src/core/utils/uni-icon.ts new file mode 100644 index 000000000000..c70fef824b41 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/utils/uni-icon.ts @@ -0,0 +1,36 @@ +import { ShadowlessElement } from '@blocksuite/block-std'; +import * as icons from '@blocksuite/icons/lit'; +import { css, html, type TemplateResult } from 'lit'; +import { property } from 'lit/decorators.js'; + +import { uniMap } from './uni-component/operation.js'; +import { createUniComponentFromWebComponent } from './uni-component/uni-component.js'; + +export class AffineLitIcon extends ShadowlessElement { + static override styles = css` + affine-microsheet-lit-icon { + display: flex; + align-items: center; + justify-content: center; + } + + affine-microsheet-lit-icon svg { + fill: var(--affine-icon-color); + } + `; + + protected override render(): unknown { + const createIcon = icons[this.name] as () => TemplateResult; + return html`${createIcon?.()}`; + } + + @property({ attribute: false }) + accessor name!: keyof typeof icons; +} + +const litIcon = createUniComponentFromWebComponent<{ name: string }>( + AffineLitIcon +); +export const createIcon = (name: keyof typeof icons) => { + return uniMap(litIcon, () => ({ name })); +}; diff --git a/packages/affine/microsheet-data-view/src/core/utils/utils.ts b/packages/affine/microsheet-data-view/src/core/utils/utils.ts new file mode 100644 index 000000000000..010d5447f2ed --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/utils/utils.ts @@ -0,0 +1,39 @@ +// source (2018-03-11): https://github.com/jquery/jquery/blob/master/src/css/hiddenVisibleSelectors.js +function isVisible(elem: HTMLElement) { + return ( + !!elem && + !!(elem.offsetWidth || elem.offsetHeight || elem.getClientRects().length) + ); +} + +export function onClickOutside( + element: HTMLElement, + callback: (element: HTMLElement, target: HTMLElement) => void, + event: 'click' | 'mousedown' = 'click', + reusable = false +): () => void { + const outsideClickListener = (event: Event) => { + // support shadow dom + const path = event.composedPath && event.composedPath(); + const isOutside = path + ? path.indexOf(element) < 0 + : !element.contains(event.target as Node) && isVisible(element); + + if (!isOutside) return; + + callback(element, event.target as HTMLElement); + // if reuseable, need to manually remove the listener + if (!reusable) removeClickListener(); + }; + + document.addEventListener(event, outsideClickListener); + const removeClickListener = () => { + document.removeEventListener(event, outsideClickListener); + }; + + return removeClickListener; +} + +export const getResultInRange = (value: number, min: number, max: number) => { + return Math.max(min, Math.min(max, value)); +}; diff --git a/packages/affine/microsheet-data-view/src/core/view-manager/cell.ts b/packages/affine/microsheet-data-view/src/core/view-manager/cell.ts new file mode 100644 index 000000000000..94cd39595ade --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/view-manager/cell.ts @@ -0,0 +1,79 @@ +import { computed, type ReadonlySignal } from '@preact/signals-core'; + +import type { Property } from './property.js'; +import type { Row } from './row.js'; +import type { SingleView } from './single-view.js'; + +export interface Cell< + Value = unknown, + Data extends Record = Record, +> { + readonly rowId: string; + readonly view: SingleView; + readonly row: Row; + readonly propertyId: string; + readonly property: Property; + readonly isEmpty$: ReadonlySignal; + readonly stringValue$: ReadonlySignal; + readonly jsonValue$: ReadonlySignal; + + readonly value$: ReadonlySignal; + valueSet(value: Value | undefined): void; +} + +export class CellBase< + Value = unknown, + Data extends Record = Record, +> implements Cell +{ + meta$ = computed(() => { + return this.view.manager.dataSource.propertyMetaGet( + this.property.type$.value + ); + }); + + value$ = computed(() => { + return this.view.manager.dataSource.cellValueGet( + this.rowId, + this.propertyId + ) as Value; + }); + + isEmpty$: ReadonlySignal = computed(() => { + return this.meta$.value.config.isEmpty(this.value$.value); + }); + + jsonValue$: ReadonlySignal = computed(() => { + return this.view.cellJsonValueGet(this.rowId, this.propertyId); + }); + + property$ = computed(() => { + return this.view.propertyGet(this.propertyId) as Property; + }); + + stringValue$: ReadonlySignal = computed(() => { + return this.view.cellStringValueGet(this.rowId, this.propertyId)!; + }); + + get property(): Property { + return this.property$.value; + } + + get row(): Row { + return this.view.rowGet(this.rowId); + } + + constructor( + public view: SingleView, + public propertyId: string, + public rowId: string + ) {} + + valueSet(value: unknown | undefined): void { + this.view.manager.dataSource.cellValueChange( + this.rowId, + this.propertyId, + value + ); + } +} diff --git a/packages/affine/microsheet-data-view/src/core/view-manager/index.ts b/packages/affine/microsheet-data-view/src/core/view-manager/index.ts new file mode 100644 index 000000000000..98a19b6eb9b3 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/view-manager/index.ts @@ -0,0 +1,2 @@ +export * from './single-view.js'; +export * from './view-manager.js'; diff --git a/packages/affine/microsheet-data-view/src/core/view-manager/property.ts b/packages/affine/microsheet-data-view/src/core/view-manager/property.ts new file mode 100644 index 000000000000..b12beda4f4e6 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/view-manager/property.ts @@ -0,0 +1,169 @@ +import { computed, type ReadonlySignal } from '@preact/signals-core'; + +import type { TType } from '../logical/typesystem.js'; +import type { CellRenderer } from '../property/index.js'; +import type { PropertyDataUpdater } from '../types.js'; +import type { UniComponent } from '../utils/uni-component/index.js'; +import type { Cell } from './cell.js'; +import type { SingleView } from './single-view.js'; + +export interface Property< + Value = unknown, + Data extends Record = Record, +> { + readonly id: string; + readonly index: number; + readonly view: SingleView; + readonly isFirst: boolean; + readonly isLast: boolean; + readonly readonly$: ReadonlySignal; + readonly renderer$: ReadonlySignal; + readonly cells$: ReadonlySignal; + readonly dataType$: ReadonlySignal; + readonly icon?: UniComponent; + + readonly delete?: () => void; + readonly duplicate?: () => void; + + cellGet(rowId: string): Cell; + + readonly data$: ReadonlySignal; + dataUpdate(updater: PropertyDataUpdater): void; + + readonly type$: ReadonlySignal; + readonly typeSet?: (type: string) => void; + + readonly name$: ReadonlySignal; + nameSet(name: string): void; + + readonly hide$: ReadonlySignal; + hideSet(hide: boolean): void; + + valueGet(rowId: string): Value | undefined; + valueSet(rowId: string, value: Value | undefined): void; + + stringValueGet(rowId: string): string; + valueSetFromString(rowId: string, value: string): void; +} + +export abstract class PropertyBase< + Value = unknown, + Data extends Record = Record, +> implements Property +{ + cells$ = computed(() => { + return this.view.rows$.value.map(id => this.cellGet(id)); + }); + + data$ = computed(() => { + return this.view.propertyDataGet(this.id) as Data; + }); + + dataType$ = computed(() => { + return this.view.propertyDataTypeGet(this.id)!; + }); + + hide$ = computed(() => { + return this.view.propertyHideGet(this.id); + }); + + name$ = computed(() => { + return this.view.propertyNameGet(this.id); + }); + + readonly$ = computed(() => { + return this.view.readonly$.value || this.view.propertyReadonlyGet(this.id); + }); + + type$ = computed(() => { + return this.view.propertyTypeGet(this.id)!; + }); + + renderer$ = computed(() => { + return this.view.propertyMetaGet(this.type$.value)?.renderer.cellRenderer; + }); + + get delete(): (() => void) | undefined { + return () => this.view.propertyDelete(this.id); + } + + get duplicate(): (() => void) | undefined { + return () => this.view.propertyDuplicate(this.id); + } + + get icon(): UniComponent | undefined { + if (!this.type$.value) return undefined; + return this.view.IconGet(this.type$.value); + } + + get id(): string { + return this.propertyId; + } + + get index(): number { + return this.view.propertyIndexGet(this.id); + } + + get isFirst(): boolean { + return this.view.propertyIndexGet(this.id) === 0; + } + + get isLast(): boolean { + return ( + this.view.propertyIndexGet(this.id) === + this.view.properties$.value.length - 1 + ); + } + + get typeSet(): undefined | ((type: string) => void) { + return type => this.view.propertyTypeSet(this.id, type); + } + + constructor( + public view: SingleView, + public propertyId: string + ) {} + + cellGet(rowId: string): Cell { + return this.view.cellGet(rowId, this.id) as Cell; + } + + dataUpdate(updater: PropertyDataUpdater): void { + const data = this.data$.value; + this.view.propertyDataSet(this.id, { + ...data, + ...updater(data), + }); + } + + hideSet(hide: boolean): void { + this.view.propertyHideSet(this.id, hide); + } + + nameSet(name: string): void { + this.view.propertyNameSet(this.id, name); + } + + stringValueGet(rowId: string): string { + return this.cellGet(rowId).stringValue$.value; + } + + valueGet(rowId: string): Value | undefined { + return this.cellGet(rowId).value$.value; + } + + valueSet(rowId: string, value: Value | undefined): void { + return this.cellGet(rowId).valueSet(value); + } + + valueSetFromString(rowId: string, value: string): void { + const data = this.view.propertyParseValueFromString(this.id, value); + if (!data) { + return; + } + if (data.data) { + this.dataUpdate(() => data.data as Data); + } + this.valueSet(rowId, data.value as Value); + } +} diff --git a/packages/affine/microsheet-data-view/src/core/view-manager/row.ts b/packages/affine/microsheet-data-view/src/core/view-manager/row.ts new file mode 100644 index 000000000000..99057bc7492a --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/view-manager/row.ts @@ -0,0 +1,23 @@ +import { computed, type ReadonlySignal } from '@preact/signals-core'; + +import type { SingleView } from './single-view.js'; + +import { type Cell, CellBase } from './cell.js'; + +export interface Row { + readonly cells$: ReadonlySignal; + readonly rowId: string; +} + +export class RowBase implements Row { + cells$ = computed(() => { + return this.singleView.propertyIds$.value.map(propertyId => { + return new CellBase(this.singleView, propertyId, this.rowId); + }); + }); + + constructor( + readonly singleView: SingleView, + readonly rowId: string + ) {} +} diff --git a/packages/affine/microsheet-data-view/src/core/view-manager/single-view.ts b/packages/affine/microsheet-data-view/src/core/view-manager/single-view.ts new file mode 100644 index 000000000000..214667f52c96 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/view-manager/single-view.ts @@ -0,0 +1,425 @@ +import type { InsertToPosition } from '@blocksuite/affine-shared/utils'; + +import { computed, type ReadonlySignal, signal } from '@preact/signals-core'; + +import type { FilterGroup, Variable } from '../common/ast.js'; +import type { DataViewContextKey } from '../common/data-source/context.js'; +import type { TType } from '../logical/typesystem.js'; +import type { PropertyMetaConfig } from '../property/property-config.js'; +import type { MicrosheetFlags } from '../types.js'; +import type { UniComponent } from '../utils/uni-component/index.js'; +import type { DataViewDataType, ViewMeta } from '../view/data-view.js'; +import type { Property } from './property.js'; +import type { ViewManager } from './view-manager.js'; + +import { type Cell, CellBase } from './cell.js'; +import { type Row, RowBase } from './row.js'; + +export type MainProperties = { + titleColumn?: string; + iconColumn?: string; + imageColumn?: string; +}; + +export interface SingleView< + ViewData extends DataViewDataType = DataViewDataType, +> { + readonly id: string; + readonly type: string; + readonly manager: ViewManager; + readonly meta: ViewMeta; + readonly readonly$: ReadonlySignal; + delete(): void; + duplicate(): void; + + data$: ReadonlySignal; + dataUpdate(updater: (viewData: ViewData) => Partial): void; + + readonly name$: ReadonlySignal; + nameSet(name: string): void; + + readonly propertyIds$: ReadonlySignal; + readonly propertiesWithoutFilter$: ReadonlySignal; + readonly properties$: ReadonlySignal; + readonly detailProperties$: ReadonlySignal; + readonly rows$: ReadonlySignal; + + readonly filter$: ReadonlySignal; + filterSet(filter: FilterGroup): void; + + readonly vars$: ReadonlySignal; + + readonly featureFlags$: ReadonlySignal; + + cellValueGet(rowId: string, propertyId: string): unknown; + cellValueSet(rowId: string, propertyId: string, value: unknown): void; + + cellRefGet(rowId: string, propertyId: string): unknown; + + cellJsonValueGet(rowId: string, propertyId: string): unknown; + cellStringValueGet(rowId: string, propertyId: string): string | undefined; + cellRenderValueSet(rowId: string, propertyId: string, value: unknown): void; + cellGet(rowId: string, propertyId: string): Cell; + + propertyParseValueFromString( + propertyId: string, + value: string + ): + | { + value: unknown; + data?: Record; + } + | undefined; + + rowAdd(insertPosition: InsertToPosition): string; + rowDelete(ids: string[]): void; + rowMove(rowId: string, position: InsertToPosition): void; + rowGet(rowId: string): Row; + + rowPrevGet(rowId: string): string; + rowNextGet(rowId: string): string; + + readonly propertyMetas: PropertyMetaConfig[]; + propertyAdd(toAfterOfProperty: InsertToPosition, type?: string): string; + propertyDelete(propertyId: string): void; + propertyDuplicate(propertyId: string): void; + propertyGet(propertyId: string): Property; + propertyMetaGet(type: string): PropertyMetaConfig | undefined; + + propertyPreGet(propertyId: string): Property | undefined; + propertyNextGet(propertyId: string): Property | undefined; + + propertyNameGet(propertyId: string): string; + propertyNameSet(propertyId: string, name: string): void; + + propertyTypeGet(propertyId: string): string | undefined; + propertyTypeSet(propertyId: string, type: string): void; + + propertyHideGet(propertyId: string): boolean; + propertyHideSet(propertyId: string, hide: boolean): void; + + propertyDataGet(propertyId: string): Record; + propertyDataSet(propertyId: string, data: Record): void; + + propertyDataTypeGet(propertyId: string): TType | undefined; + propertyIndexGet(propertyId: string): number; + propertyIdGetByIndex(index: number): string; + propertyReadonlyGet(propertyId: string): boolean; + propertyMove(propertyId: string, position: InsertToPosition): void; + + IconGet(type: string): UniComponent | undefined; + + contextGet(key: DataViewContextKey): T; + + mainProperties$: ReadonlySignal; +} + +export abstract class SingleViewBase< + ViewData extends DataViewDataType = DataViewDataType, +> implements SingleView +{ + private searchString = signal(''); + + data$ = computed(() => { + return this.dataSource.viewDataGet(this.id) as ViewData | undefined; + }); + + abstract detailProperties$: ReadonlySignal; + + abstract filter$: ReadonlySignal; + + filterVisible$ = computed(() => { + return (this.filter$.value?.conditions.length ?? 0) > 0; + }); + + abstract mainProperties$: ReadonlySignal; + + name$: ReadonlySignal = computed(() => { + return this.data$.value?.name ?? ''; + }); + + abstract propertyIds$: ReadonlySignal; + + properties$ = computed(() => { + return this.propertyIds$.value.map( + id => this.propertyGet(id) as ReturnType + ); + }); + + abstract propertiesWithoutFilter$: ReadonlySignal; + + abstract readonly$: ReadonlySignal; + + rows$ = computed(() => { + return this.filteredRows(this.searchString.value); + }); + + vars$ = computed(() => { + return this.propertiesWithoutFilter$.value.map(id => { + const v = this.propertyGet(id); + const propertyMeta = this.dataSource.propertyMetaGet(v.type$.value); + return { + id: v.id, + name: v.name$.value, + type: propertyMeta.config.type(v.data$.value), + icon: v.icon, + }; + }); + }); + + protected get dataSource() { + return this.manager.dataSource; + } + + get featureFlags$() { + return this.dataSource.featureFlags$; + } + + get meta() { + return this.dataSource.viewMetaGet(this.type); + } + + get propertyMetas(): PropertyMetaConfig[] { + return this.dataSource.propertyMetas; + } + + abstract get type(): string; + + constructor( + public manager: ViewManager, + public id: string + ) {} + + private filteredRows(searchString: string): string[] { + return this.dataSource.rows$.value.filter(id => { + if (searchString) { + const containsSearchString = this.propertyIds$.value.some( + propertyId => { + return this.cellStringValueGet(id, propertyId) + ?.toLowerCase() + .includes(searchString?.toLowerCase()); + } + ); + if (!containsSearchString) { + return false; + } + } + return this.isShow(id); + }); + } + + cellGet(rowId: string, propertyId: string): Cell { + return new CellBase(this, propertyId, rowId); + } + + cellJsonValueGet(rowId: string, propertyId: string): unknown { + const type = this.propertyTypeGet(propertyId); + if (!type) { + return; + } + return this.dataSource + .propertyMetaGet(type) + .config.cellToJson( + this.dataSource.cellValueGet(rowId, propertyId), + this.propertyDataGet(propertyId) + ); + } + + cellRefGet(rowId: string, propertyId: string): unknown { + const cellRef = this.dataSource.cellRefGet(rowId, propertyId); + return cellRef; + } + + cellRenderValueSet(rowId: string, propertyId: string, value: unknown): void { + this.dataSource.cellValueChange(rowId, propertyId, value); + } + + cellStringValueGet(rowId: string, propertyId: string): string | undefined { + const type = this.propertyTypeGet(propertyId); + if (!type) { + return; + } + return ( + this.dataSource + .propertyMetaGet(type) + .config.cellToString( + this.dataSource.cellValueGet(rowId, propertyId), + this.propertyDataGet(propertyId) + ) ?? '' + ); + } + + cellValueGet(rowId: string, propertyId: string): unknown { + const type = this.propertyTypeGet(propertyId); + if (!type) { + return; + } + const cellValue = this.dataSource.cellValueGet(rowId, propertyId); + return ( + this.dataSource + .propertyMetaGet(type) + .config.formatValue?.(cellValue, this.propertyDataGet(propertyId)) ?? + cellValue + ); + } + + cellValueSet(rowId: string, propertyId: string, value: unknown): void { + this.dataSource.cellValueChange(rowId, propertyId, value); + } + + contextGet(key: DataViewContextKey): T { + return this.dataSource.contextGet(key); + } + + dataUpdate(updater: (viewData: ViewData) => Partial): void { + this.dataSource.viewDataUpdate(this.id, updater); + } + + delete(): void { + this.manager.viewDelete(this.id); + } + + duplicate(): void { + this.manager.viewDuplicate(this.id); + } + + abstract filterSet(filter: FilterGroup): void; + + IconGet(type: string): UniComponent | undefined { + return this.dataSource.propertyMetaGet(type).renderer.icon; + } + + abstract isShow(rowId: string): boolean; + + nameSet(name: string): void { + this.dataUpdate(() => { + return { + name, + } as ViewData; + }); + } + + propertyAdd(position: InsertToPosition, type?: string): string { + const id = this.dataSource.propertyAdd(position, type); + this.propertyMove(id, position); + return id; + } + + propertyDataGet(propertyId: string): Record { + return this.dataSource.propertyDataGet(propertyId); + } + + propertyDataSet(propertyId: string, data: Record): void { + this.dataSource.propertyDataSet(propertyId, data); + } + + propertyDataTypeGet(propertyId: string): TType | undefined { + const type = this.propertyTypeGet(propertyId); + if (!type) { + return; + } + return this.dataSource + .propertyMetaGet(type) + .config.type(this.propertyDataGet(propertyId)); + } + + propertyDelete(propertyId: string): void { + this.dataSource.propertyDelete(propertyId); + } + + propertyDuplicate(propertyId: string): void { + const id = this.dataSource.propertyDuplicate(propertyId); + this.propertyMove(id, { + before: false, + id: propertyId, + }); + } + + abstract propertyGet(propertyId: string): Property; + + abstract propertyHideGet(propertyId: string): boolean; + + abstract propertyHideSet(propertyId: string, hide: boolean): void; + + propertyIdGetByIndex(index: number): string { + return this.propertyIds$.value[index]; + } + + propertyIndexGet(propertyId: string): number { + return this.propertyIds$.value.indexOf(propertyId); + } + + propertyMetaGet(type: string): PropertyMetaConfig { + return this.dataSource.propertyMetaGet(type); + } + + abstract propertyMove(propertyId: string, position: InsertToPosition): void; + + propertyNameGet(propertyId: string): string { + return this.dataSource.propertyNameGet(propertyId); + } + + propertyNameSet(propertyId: string, name: string): void { + this.dataSource.propertyNameSet(propertyId, name); + } + + propertyNextGet(propertyId: string): Property | undefined { + return this.propertyGet( + this.propertyIdGetByIndex(this.propertyIndexGet(propertyId) + 1) + ); + } + + propertyParseValueFromString(propertyId: string, cellData: string) { + const type = this.propertyTypeGet(propertyId); + if (!type) { + return; + } + return ( + this.dataSource + .propertyMetaGet(type) + .config.cellFromString(cellData, this.propertyDataGet(propertyId)) ?? '' + ); + } + + propertyPreGet(propertyId: string): Property | undefined { + return this.propertyGet( + this.propertyIdGetByIndex(this.propertyIndexGet(propertyId) - 1) + ); + } + + propertyReadonlyGet(propertyId: string): boolean { + return this.dataSource.propertyReadonlyGet(propertyId); + } + + propertyTypeGet(propertyId: string): string | undefined { + return this.dataSource.propertyTypeGet(propertyId); + } + + propertyTypeSet(propertyId: string, type: string): void { + this.dataSource.propertyTypeSet(propertyId, type); + } + + rowAdd(insertPosition: InsertToPosition | number): string { + return this.dataSource.rowAdd(insertPosition); + } + + rowDelete(ids: string[]): void { + this.dataSource.rowDelete(ids); + } + + rowGet(rowId: string): Row { + return new RowBase(this, rowId); + } + + rowMove(rowId: string, position: InsertToPosition): void { + this.dataSource.rowMove(rowId, position); + } + + abstract rowNextGet(rowId: string): string; + + abstract rowPrevGet(rowId: string): string; + + setSearch(str: string): void { + this.searchString.value = str; + } +} diff --git a/packages/affine/microsheet-data-view/src/core/view-manager/view-manager.ts b/packages/affine/microsheet-data-view/src/core/view-manager/view-manager.ts new file mode 100644 index 000000000000..4981934fe1dd --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/view-manager/view-manager.ts @@ -0,0 +1,128 @@ +import type { InsertToPosition } from '@blocksuite/affine-shared/utils'; + +import { nanoid } from '@blocksuite/store'; +import { computed, type ReadonlySignal, signal } from '@preact/signals-core'; + +import type { DataSource } from '../common/data-source/base.js'; +import type { + DataViewDataType, + DataViewMode, + ViewMeta, +} from '../view/data-view.js'; +import type { SingleView } from './single-view.js'; + +export interface ViewManager { + viewMetas: ViewMeta[]; + dataSource: DataSource; + readonly$: ReadonlySignal; + + currentViewId$: ReadonlySignal; + currentView$: ReadonlySignal; + + setCurrentView(id: string): void; + + views$: ReadonlySignal; + + viewGet(id: string): SingleView; + + viewAdd(type: DataViewMode): string; + + viewDelete(id: string): void; + + viewDuplicate(id: string): void; + + viewDataGet(id: string): DataViewDataType | undefined; + + moveTo(id: string, position: InsertToPosition): void; + + viewChangeType(id: string, type: string): void; +} + +export class ViewManagerBase implements ViewManager { + _currentViewId$ = signal(undefined); + + views$ = computed(() => { + return this.dataSource.viewDataList$.value.map(data => data.id); + }); + + currentViewId$ = computed(() => { + return this._currentViewId$.value ?? this.views$.value[0]; + }); + + currentView$ = computed(() => { + return this.viewGet(this.currentViewId$.value); + }); + + readonly$ = computed(() => { + return this.dataSource.readonly$.value; + }); + + get viewMetas() { + return this.dataSource.viewMetas; + } + + constructor(public dataSource: DataSource) {} + + moveTo(id: string, position: InsertToPosition): void { + this.dataSource.viewDataMoveTo(id, position); + } + + setCurrentView(id: string): void { + this._currentViewId$.value = id; + } + + viewAdd(type: DataViewMode): string { + const meta = this.dataSource.viewMetaGet(type); + const data = meta.model.defaultData(this); + const id = this.dataSource.viewDataAdd({ + ...data, + id: nanoid(), + name: meta.model.defaultName, + mode: type, + }); + this.setCurrentView(id); + return id; + } + + viewChangeType(id: string, type: string): void { + const from = this.viewGet(id).type; + const meta = this.dataSource.viewMetaGet(type); + this.dataSource.viewDataUpdate(id, old => { + let data = { + ...meta.model.defaultData(this), + id: old.id, + name: old.name, + mode: type, + }; + const convertFunction = this.dataSource.viewConverts.find( + v => v.from === from && v.to === type + ); + if (convertFunction) { + data = { + ...data, + ...convertFunction.convert(old), + }; + } + return data; + }); + } + + viewDataGet(id: string): DataViewDataType | undefined { + return this.dataSource.viewDataGet(id); + } + + viewDelete(id: string): void { + this.dataSource.viewDataDelete(id); + this.setCurrentView(this.views$.value[0]); + } + + viewDuplicate(id: string): void { + const newId = this.dataSource.viewDataDuplicate(id); + this.setCurrentView(newId); + } + + viewGet(id: string): SingleView { + const meta = this.dataSource.viewMetaGetById(id); + return new meta.model.dataViewManager(this, id); + } +} diff --git a/packages/affine/microsheet-data-view/src/core/view/convert.ts b/packages/affine/microsheet-data-view/src/core/view/convert.ts new file mode 100644 index 000000000000..9ad392c23af9 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/view/convert.ts @@ -0,0 +1,27 @@ +import type { DataViewModel, GetDataFromDataViewModel } from './data-view.js'; + +export type ViewConvertFunction< + From extends DataViewModel = DataViewModel, + To extends DataViewModel = DataViewModel, +> = ( + data: GetDataFromDataViewModel +) => Partial>; +export type ViewConvertConfig = { + from: string; + to: string; + convert: ViewConvertFunction; +}; +export const createViewConvert = < + From extends DataViewModel, + To extends DataViewModel, +>( + from: From, + to: To, + convert: ViewConvertFunction +): ViewConvertConfig => { + return { + from: from.type, + to: to.type, + convert, + }; +}; diff --git a/packages/affine/microsheet-data-view/src/core/view/data-view-base.ts b/packages/affine/microsheet-data-view/src/core/view/data-view-base.ts new file mode 100644 index 000000000000..c9a0bcb8f7d8 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/view/data-view-base.ts @@ -0,0 +1,17 @@ +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { property } from 'lit/decorators.js'; + +import type { DataViewSelection } from '../types.js'; +import type { SingleView } from '../view-manager/single-view.js'; +import type { DataViewExpose, DataViewProps } from './types.js'; + +export abstract class DataViewBase< + T extends SingleView = SingleView, + Selection extends DataViewSelection = DataViewSelection, +> extends SignalWatcher(WithDisposable(ShadowlessElement)) { + abstract expose: DataViewExpose; + + @property({ attribute: false }) + accessor props!: DataViewProps; +} diff --git a/packages/affine/microsheet-data-view/src/core/view/data-view.ts b/packages/affine/microsheet-data-view/src/core/view/data-view.ts new file mode 100644 index 000000000000..f538fc0b1ba8 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/view/data-view.ts @@ -0,0 +1,77 @@ +import type { UniComponent } from '../utils/uni-component/index.js'; +import type { SingleView } from '../view-manager/single-view.js'; +import type { ViewManager } from '../view-manager/view-manager.js'; +import type { DataViewExpose, DataViewProps } from './types.js'; + +export type BasicViewDataType< + Type extends string = string, + T = NonNullable, +> = { + id: string; + name: string; + mode: Type; +} & T; + +export type DefaultViewDataType = BasicViewDataType & { + mode: string; +}; + +export type DataViewDataType = DefaultViewDataType; + +export type DataViewMode = string; + +export interface DataViewModelConfig< + Data extends DataViewDataType = DataViewDataType, +> { + defaultName: string; + dataViewManager: new ( + viewManager: ViewManager, + viewId: string + ) => SingleView; + defaultData: (viewManager: ViewManager) => Omit; +} + +export type DataViewModel< + Type extends string = DataViewMode, + Data extends DataViewDataType = DataViewDataType, +> = { + type: Type; + model: DataViewModelConfig; +}; + +export type GetDataFromDataViewModel = + Model extends DataViewModel ? R : never; + +export interface DataViewRendererConfig { + view: UniComponent< + { + props: DataViewProps; + }, + { expose: DataViewExpose } + >; + icon: UniComponent; +} + +export type ViewMeta< + Type extends string = DataViewMode, + Data extends DataViewDataType = DataViewDataType, +> = DataViewModel & { + renderer: DataViewRendererConfig; +}; + +export const viewType = (type: Type) => ({ + type, + createModel: ( + model: DataViewModelConfig + ): DataViewModel & { + createMeta: (renderer: DataViewRendererConfig) => ViewMeta; + } => ({ + type, + model, + createMeta: renderer => ({ + type, + model, + renderer, + }), + }), +}); diff --git a/packages/affine/microsheet-data-view/src/core/view/index.ts b/packages/affine/microsheet-data-view/src/core/view/index.ts new file mode 100644 index 000000000000..3b38c96b44c8 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/view/index.ts @@ -0,0 +1,3 @@ +export * from './convert.js'; +export * from './data-view.js'; +export * from './types.js'; diff --git a/packages/affine/microsheet-data-view/src/core/view/types.ts b/packages/affine/microsheet-data-view/src/core/view/types.ts new file mode 100644 index 000000000000..0d246b69692f --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/view/types.ts @@ -0,0 +1,54 @@ +import type { InsertToPosition } from '@blocksuite/affine-shared/utils'; +import type { + BlockStdScope, + EventName, + UIEventHandler, +} from '@blocksuite/block-std'; +import type { Disposable } from '@blocksuite/global/utils'; +import type { ReadonlySignal } from '@preact/signals-core'; + +import type { DataSource } from '../common/index.js'; +import type { DataViewRenderer } from '../data-view.js'; +import type { DataViewSelection } from '../types.js'; +import type { SingleView } from '../view-manager/index.js'; +import type { DataViewWidget } from '../widget/index.js'; + +export interface DataViewProps< + T extends SingleView = SingleView, + Selection extends DataViewSelection = DataViewSelection, +> { + dataViewEle: DataViewRenderer; + + headerWidget?: DataViewWidget; + + view: T; + dataSource: DataSource; + + bindHotkey: (hotkeys: Record) => Disposable; + + handleEvent: (name: EventName, handler: UIEventHandler) => Disposable; + + setSelection: (selection?: Selection) => void; + + selection$: ReadonlySignal; + + virtualPadding$: ReadonlySignal; + + onDrag?: (evt: MouseEvent, id: string) => () => void; + + std: BlockStdScope; +} + +export interface DataViewExpose { + addRow?(position: InsertToPosition | number): void; + + getSelection?(): DataViewSelection | undefined; + + focusFirstCell(): void; + + showIndicator?(evt: MouseEvent): boolean; + + hideIndicator?(): void; + + moveTo?(id: string, evt: MouseEvent): void; +} diff --git a/packages/affine/microsheet-data-view/src/core/widget/index.ts b/packages/affine/microsheet-data-view/src/core/widget/index.ts new file mode 100644 index 000000000000..d4702960d547 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/widget/index.ts @@ -0,0 +1 @@ +export * from './types.js'; diff --git a/packages/affine/microsheet-data-view/src/core/widget/types.ts b/packages/affine/microsheet-data-view/src/core/widget/types.ts new file mode 100644 index 000000000000..1250bc0f116a --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/widget/types.ts @@ -0,0 +1,9 @@ +import type { UniComponent } from '../utils/uni-component/index.js'; +import type { DataViewExpose } from '../view/types.js'; +import type { SingleView } from '../view-manager/single-view.js'; + +export type DataViewWidgetProps = { + view: SingleView; + viewMethods: DataViewExpose; +}; +export type DataViewWidget = UniComponent; diff --git a/packages/affine/microsheet-data-view/src/core/widget/widget-base.ts b/packages/affine/microsheet-data-view/src/core/widget/widget-base.ts new file mode 100644 index 000000000000..750a2afbd907 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/widget/widget-base.ts @@ -0,0 +1,26 @@ +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { property } from 'lit/decorators.js'; + +import type { DataViewExpose } from '../view/types.js'; +import type { SingleView } from '../view-manager/single-view.js'; +import type { DataViewWidgetProps } from './types.js'; + +export class WidgetBase + extends SignalWatcher(WithDisposable(ShadowlessElement)) + implements DataViewWidgetProps +{ + get dataSource() { + return this.view.manager.dataSource; + } + + get viewManager() { + return this.view.manager; + } + + @property({ attribute: false }) + accessor view!: SingleView; + + @property({ attribute: false }) + accessor viewMethods!: DataViewExpose; +} diff --git a/packages/affine/microsheet-data-view/src/effects.ts b/packages/affine/microsheet-data-view/src/effects.ts new file mode 100644 index 000000000000..640eb2b02bb0 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/effects.ts @@ -0,0 +1,264 @@ +import { Overflow } from './core/common/component/overflow/overflow.js'; +import { RecordDetail } from './core/common/detail/detail.js'; +import { RecordField } from './core/common/detail/field.js'; +import { BooleanGroupView } from './core/common/group-by/renderer/boolean-group.js'; +import { NumberGroupView } from './core/common/group-by/renderer/number-group.js'; +import { SelectGroupView } from './core/common/group-by/renderer/select-group.js'; +import { StringGroupView } from './core/common/group-by/renderer/string-group.js'; +import { GroupSetting } from './core/common/group-by/setting.js'; +import { DateLiteral } from './core/common/literal/renderer/date-literal.js'; +import { + BooleanLiteral, + NumberLiteral, + StringLiteral, +} from './core/common/literal/renderer/literal-element.js'; +import { + MultiTagLiteral, + TagLiteral, +} from './core/common/literal/renderer/tag-literal.js'; +import { TagLiteral as UnionTagLiteral } from './core/common/literal/renderer/union-string.js'; +import { DataViewPropertiesSettingView } from './core/common/properties.js'; +import { VariableRefView } from './core/common/ref/ref.js'; +import { DataViewRenderer } from './core/data-view.js'; +import { + AffineLitIcon, + MultiTagSelect, + MultiTagView, + UniAnyRender, + UniLit, +} from './core/index.js'; +import { AnyRender } from './core/utils/uni-component/render-template.js'; +import { CheckboxCell } from './property-presets/checkbox/cell-renderer.js'; +import { + DateCell, + DateCellEditing, +} from './property-presets/date/cell-renderer.js'; +import { TextCell as ImageTextCell } from './property-presets/image/cell-renderer.js'; +import { + MultiSelectCell, + MultiSelectCellEditing, +} from './property-presets/multi-select/cell-renderer.js'; +import { + NumberCell, + NumberCellEditing, +} from './property-presets/number/cell-renderer.js'; +import { + ProgressCell, + ProgressCellEditing, +} from './property-presets/progress/cell-renderer.js'; +import { + SelectCell, + SelectCellEditing, +} from './property-presets/select/cell-renderer.js'; +import { + TextCell, + TextCellEditing, +} from './property-presets/text/cell-renderer.js'; +import { DataViewKanban, DataViewTable } from './view-presets/index.js'; +import { KanbanCard } from './view-presets/kanban/card.js'; +import { KanbanCell } from './view-presets/kanban/cell.js'; +import { KanbanGroup } from './view-presets/kanban/group.js'; +import { KanbanHeader } from './view-presets/kanban/header.js'; +import { MicrosheetCellContainer } from './view-presets/table/cell.js'; +import { DragToFillElement } from './view-presets/table/controller/drag-to-fill.js'; +import { SelectionElement } from './view-presets/table/controller/selection.js'; +import { TableGroup } from './view-presets/table/group.js'; +import { MicrosheetColumnHeader } from './view-presets/table/header/column-header.js'; +import { DataViewColumnPreview } from './view-presets/table/header/column-renderer.js'; +import { MicrosheetHeaderColumn } from './view-presets/table/header/microsheet-header-column.js'; +import { MicrosheetNumberFormatBar } from './view-presets/table/header/number-format-bar.js'; +import { TableVerticalIndicator } from './view-presets/table/header/vertical-indicator.js'; +import { TableRow } from './view-presets/table/row/row.js'; +import { RowSelectCheckbox } from './view-presets/table/row/row-select-checkbox.js'; +import { MicrosheetColumnStats } from './view-presets/table/stats/column-stats-bar.js'; +import { MicrosheetColumnStatsCell } from './view-presets/table/stats/column-stats-column.js'; +import { FilterConditionView } from './widget-presets/filter/condition.js'; +import { FilterBar } from './widget-presets/filter/filter-bar.js'; +import { FilterGroupView } from './widget-presets/filter/filter-group.js'; +import { FilterRootView } from './widget-presets/filter/filter-root.js'; +import { DataViewHeaderToolsFilter } from './widget-presets/tools/presets/filter/filter.js'; +import { DataViewHeaderToolsSearch } from './widget-presets/tools/presets/search/search.js'; +import { DataViewHeaderToolsAddRow } from './widget-presets/tools/presets/table-add-row/add-row.js'; +import { NewRecordPreview } from './widget-presets/tools/presets/table-add-row/new-record-preview.js'; +import { DataViewHeaderToolsViewOptions } from './widget-presets/tools/presets/view-options/view-options.js'; +import { DataViewHeaderTools } from './widget-presets/tools/tools-renderer.js'; +import { DataViewHeaderViews } from './widget-presets/views-bar/views.js'; + +export function effects() { + customElements.define('affine-microsheet-progress-cell', ProgressCell); + customElements.define( + 'affine-microsheet-progress-cell-editing', + ProgressCellEditing + ); + customElements.define( + 'microsheet-data-view-header-tools', + DataViewHeaderTools + ); + customElements.define('affine-microsheet-number-cell', NumberCell); + customElements.define( + 'affine-microsheet-number-cell-editing', + NumberCellEditing + ); + customElements.define( + 'affine-microsheet-cell-container', + MicrosheetCellContainer + ); + customElements.define( + 'affine-microsheet-data-view-renderer', + DataViewRenderer + ); + customElements.define('microsheet-any-render', AnyRender); + customElements.define('affine-microsheet-image-cell', ImageTextCell); + customElements.define('affine-microsheet-date-cell', DateCell); + customElements.define('affine-microsheet-date-cell-editing', DateCellEditing); + customElements.define( + 'microsheet-data-view-properties-setting', + DataViewPropertiesSettingView + ); + customElements.define('affine-microsheet-checkbox-cell', CheckboxCell); + customElements.define('affine-microsheet-text-cell', TextCell); + customElements.define('affine-microsheet-text-cell-editing', TextCellEditing); + customElements.define('affine-microsheet-select-cell', SelectCell); + customElements.define( + 'affine-microsheet-select-cell-editing', + SelectCellEditing + ); + customElements.define('affine-microsheet-multi-select-cell', MultiSelectCell); + customElements.define( + 'affine-microsheet-multi-select-cell-editing', + MultiSelectCellEditing + ); + customElements.define( + 'affine-microsheet-data-view-record-field', + RecordField + ); + customElements.define('microsheet-data-view-drag-to-fill', DragToFillElement); + customElements.define('affine-microsheet-data-view-table-group', TableGroup); + customElements.define( + 'affine-microsheet-data-view-column-preview', + DataViewColumnPreview + ); + customElements.define('microsheet-component-overflow', Overflow); + customElements.define( + 'microsheet-data-view-group-title-select-view', + SelectGroupView + ); + customElements.define( + 'microsheet-data-view-group-title-string-view', + StringGroupView + ); + customElements.define('affine-microsheet-data-view-kanban-card', KanbanCard); + customElements.define('microsheet-filter-bar', FilterBar); + customElements.define( + 'microsheet-data-view-group-title-number-view', + NumberGroupView + ); + customElements.define('affine-microsheet-data-view-kanban-cell', KanbanCell); + customElements.define('affine-microsheet-lit-icon', AffineLitIcon); + customElements.define( + 'microsheet-filter-condition-view', + FilterConditionView + ); + customElements.define( + 'microsheet-data-view-literal-boolean-view', + BooleanLiteral + ); + customElements.define( + 'microsheet-data-view-literal-number-view', + NumberLiteral + ); + customElements.define( + 'microsheet-data-view-literal-string-view', + StringLiteral + ); + customElements.define('microsheet-data-view-group-setting', GroupSetting); + customElements.define('microsheet-data-view-literal-tag-view', TagLiteral); + customElements.define( + 'microsheet-data-view-literal-multi-tag-view', + MultiTagLiteral + ); + customElements.define( + 'microsheet-data-view-literal-union-string-view', + UnionTagLiteral + ); + customElements.define('affine-microsheet-multi-tag-select', MultiTagSelect); + customElements.define( + 'microsheet-data-view-group-title-boolean-view', + BooleanGroupView + ); + customElements.define('microsheet-data-view-literal-date-view', DateLiteral); + customElements.define('affine-microsheet-table', DataViewTable); + customElements.define('affine-microsheet-multi-tag-view', MultiTagView); + customElements.define( + 'microsheet-data-view-header-tools-search', + DataViewHeaderToolsSearch + ); + customElements.define('microsheet-uni-lit', UniLit); + customElements.define('microsheet-uni-any-render', UniAnyRender); + customElements.define('microsheet-filter-group-view', FilterGroupView); + customElements.define( + 'microsheet-data-view-header-tools-add-row', + DataViewHeaderToolsAddRow + ); + customElements.define( + 'microsheet-data-view-table-selection', + SelectionElement + ); + customElements.define( + 'affine-microsheet-new-record-preview', + NewRecordPreview + ); + customElements.define( + 'affine-microsheet-data-view-kanban-group', + KanbanGroup + ); + customElements.define( + 'microsheet-data-view-header-tools-filter', + DataViewHeaderToolsFilter + ); + customElements.define( + 'microsheet-data-view-header-tools-view-options', + DataViewHeaderToolsViewOptions + ); + customElements.define('affine-microsheet-data-view-kanban', DataViewKanban); + customElements.define( + 'affine-microsheet-data-view-kanban-header', + KanbanHeader + ); + customElements.define('microsheet-variable-ref-view', VariableRefView); + customElements.define( + 'affine-microsheet-data-view-record-detail', + RecordDetail + ); + customElements.define('microsheet-filter-root-view', FilterRootView); + customElements.define( + 'affine-microsheet-column-header', + MicrosheetColumnHeader + ); + customElements.define( + 'microsheet-data-view-header-views', + DataViewHeaderViews + ); + customElements.define( + 'affine-microsheet-number-format-bar', + MicrosheetNumberFormatBar + ); + customElements.define( + 'affine-microsheet-header-column', + MicrosheetHeaderColumn + ); + customElements.define('microsheet-row-select-checkbox', RowSelectCheckbox); + customElements.define( + 'microsheet-data-view-table-vertical-indicator', + TableVerticalIndicator + ); + customElements.define('microsheet-data-view-table-row', TableRow); + customElements.define( + 'affine-microsheet-column-stats', + MicrosheetColumnStats + ); + customElements.define( + 'affine-microsheet-column-stats-cell', + MicrosheetColumnStatsCell + ); +} diff --git a/packages/affine/microsheet-data-view/src/index.ts b/packages/affine/microsheet-data-view/src/index.ts new file mode 100644 index 000000000000..17f45946d4e5 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/index.ts @@ -0,0 +1 @@ +export * from './core/index.js'; diff --git a/packages/affine/microsheet-data-view/src/property-presets/checkbox/cell-renderer.ts b/packages/affine/microsheet-data-view/src/property-presets/checkbox/cell-renderer.ts new file mode 100644 index 000000000000..122b787b311a --- /dev/null +++ b/packages/affine/microsheet-data-view/src/property-presets/checkbox/cell-renderer.ts @@ -0,0 +1,121 @@ +import { CheckBoxCkeckSolidIcon, CheckBoxUnIcon } from '@blocksuite/icons/lit'; +import { css, html } from 'lit'; +import { query } from 'lit/decorators.js'; + +import { BaseCellRenderer } from '../../core/property/index.js'; +import { createFromBaseCellRenderer } from '../../core/property/renderer.js'; +import { createIcon } from '../../core/utils/uni-icon.js'; +import { checkboxPropertyModelConfig } from './define.js'; + +const playCheckAnimation = async ( + refElement: Element, + { left = 0, size = 20 }: { left?: number; size?: number } = {} +) => { + const sparkingEl = document.createElement('div'); + sparkingEl.classList.add('affine-check-animation'); + if (size < 20) { + console.warn('If the size is less than 20, the animation may be abnormal.'); + } + sparkingEl.style.cssText = ` + position: absolute; + width: ${size}px; + height: ${size}px; + border-radius: 50%; + `; + sparkingEl.style.left = `${left}px`; + refElement.append(sparkingEl); + + await sparkingEl.animate( + [ + { + boxShadow: + '0 -18px 0 -8px #1e96eb, 16px -8px 0 -8px #1e96eb, 16px 8px 0 -8px #1e96eb, 0 18px 0 -8px #1e96eb, -16px 8px 0 -8px #1e96eb, -16px -8px 0 -8px #1e96eb', + }, + ], + { duration: 240, easing: 'ease', fill: 'forwards' } + ).finished; + await sparkingEl.animate( + [ + { + boxShadow: + '0 -36px 0 -10px transparent, 32px -16px 0 -10px transparent, 32px 16px 0 -10px transparent, 0 36px 0 -10px transparent, -32px 16px 0 -10px transparent, -32px -16px 0 -10px transparent', + }, + ], + { duration: 360, easing: 'ease', fill: 'forwards' } + ).finished; + + sparkingEl.remove(); +}; + +export class CheckboxCell extends BaseCellRenderer { + static override styles = css` + affine-microsheet-checkbox-cell { + display: block; + width: 100%; + cursor: pointer; + } + + .affine-microsheet-checkbox-container { + height: 100%; + } + + .affine-microsheet-checkbox { + display: flex; + align-items: center; + height: var(--data-view-cell-text-line-height); + width: 100%; + position: relative; + } + .affine-microsheet-checkbox svg { + width: 16px; + height: 16px; + } + `; + + override beforeEnterEditMode() { + const checked = !this.value; + + this.onChange(checked); + if (checked) { + playCheckAnimation(this._checkbox, { left: -2 }).catch(console.error); + } + return false; + } + + override onCopy(_e: ClipboardEvent) { + _e.preventDefault(); + } + + override onCut(_e: ClipboardEvent) { + _e.preventDefault(); + } + + override onPaste(_e: ClipboardEvent) { + _e.preventDefault(); + } + + override render() { + const checked = this.value ?? false; + const icon = checked + ? CheckBoxCkeckSolidIcon({ style: `color:#1E96EB` }) + : CheckBoxUnIcon(); + return html`
+
+ ${icon} +
+
`; + } + + @query('.affine-microsheet-checkbox') + private accessor _checkbox!: HTMLDivElement; +} + +export const checkboxPropertyConfig = + checkboxPropertyModelConfig.createPropertyMeta({ + icon: createIcon('CheckBoxCheckLinearIcon'), + cellRenderer: { + view: createFromBaseCellRenderer(CheckboxCell), + }, + }); diff --git a/packages/affine/microsheet-data-view/src/property-presets/checkbox/define.ts b/packages/affine/microsheet-data-view/src/property-presets/checkbox/define.ts new file mode 100644 index 000000000000..cfc25f4e15df --- /dev/null +++ b/packages/affine/microsheet-data-view/src/property-presets/checkbox/define.ts @@ -0,0 +1,19 @@ +import { tBoolean } from '../../core/logical/data-type.js'; +import { propertyType } from '../../core/property/property-config.js'; + +export const checkboxPropertyType = propertyType('checkbox'); + +export const checkboxPropertyModelConfig = + checkboxPropertyType.modelConfig({ + name: 'Checkbox', + type: () => tBoolean.create(), + defaultData: () => ({}), + cellToString: data => (data ? 'True' : 'False'), + cellFromString: data => { + return { + value: data !== 'False', + }; + }, + cellToJson: data => data ?? null, + isEmpty: () => false, + }); diff --git a/packages/affine/microsheet-data-view/src/property-presets/converts.ts b/packages/affine/microsheet-data-view/src/property-presets/converts.ts new file mode 100644 index 000000000000..ca3c059ffdeb --- /dev/null +++ b/packages/affine/microsheet-data-view/src/property-presets/converts.ts @@ -0,0 +1,45 @@ +import { clamp } from '@blocksuite/affine-shared/utils'; + +import { createPropertyConvert } from '../core/index.js'; +import { multiSelectPropertyModelConfig } from './multi-select/define.js'; +import { numberPropertyModelConfig } from './number/define.js'; +import { progressPropertyModelConfig } from './progress/define.js'; +import { selectPropertyModelConfig } from './select/define.js'; + +export const presetPropertyConverts = [ + createPropertyConvert( + multiSelectPropertyModelConfig, + selectPropertyModelConfig, + (property, cells) => ({ + property, + cells: cells.map(v => v?.[0]), + }) + ), + createPropertyConvert( + numberPropertyModelConfig, + progressPropertyModelConfig, + (_property, cells) => ({ + property: {}, + cells: cells.map(v => clamp(v ?? 0, 0, 100)), + }) + ), + createPropertyConvert( + progressPropertyModelConfig, + numberPropertyModelConfig, + (_property, cells) => ({ + property: { + decimal: 0, + format: 'number' as const, + }, + cells: cells.map(v => v), + }) + ), + createPropertyConvert( + selectPropertyModelConfig, + multiSelectPropertyModelConfig, + (property, cells) => ({ + property, + cells: cells.map(v => (v ? [v] : undefined)), + }) + ), +]; diff --git a/packages/affine/microsheet-data-view/src/property-presets/date/cell-renderer.ts b/packages/affine/microsheet-data-view/src/property-presets/date/cell-renderer.ts new file mode 100644 index 000000000000..75132059c753 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/property-presets/date/cell-renderer.ts @@ -0,0 +1,151 @@ +import { DatePicker } from '@blocksuite/affine-components/date-picker'; +import { createLitPortal } from '@blocksuite/affine-components/portal'; +import { flip, offset } from '@floating-ui/dom'; +import { baseTheme } from '@toeverything/theme'; +import { format } from 'date-fns/format'; +import { css, html, unsafeCSS } from 'lit'; +import { state } from 'lit/decorators.js'; + +import { BaseCellRenderer } from '../../core/property/index.js'; +import { createFromBaseCellRenderer } from '../../core/property/renderer.js'; +import { createIcon } from '../../core/utils/uni-icon.js'; +import { datePropertyModelConfig } from './define.js'; + +export class DateCell extends BaseCellRenderer { + static override styles = css` + affine-microsheet-date-cell { + width: 100%; + } + + .affine-microsheet-date { + 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); + height: var(--data-view-cell-text-line-height); + } + + input.affine-microsheet-date[type='date']::-webkit-calendar-picker-indicator { + display: none; + } + `; + + override render() { + const value = this.value ? format(this.value, 'yyyy/MM/dd') : ''; + if (!value) { + return ''; + } + return html`
${value}
`; + } +} + +export class DateCellEditing extends BaseCellRenderer { + static override styles = css` + affine-microsheet-date-cell-editing { + width: 100%; + cursor: text; + } + + .affine-microsheet-date:focus { + outline: none; + } + `; + + private _prevPortalAbortController: AbortController | null = null; + + private openDatePicker = () => { + if ( + this._prevPortalAbortController && + !this._prevPortalAbortController.signal.aborted + ) + return; + + this._prevPortalAbortController?.abort(); + const abortController = new AbortController(); + abortController.signal.addEventListener( + 'abort', + () => { + this.selectCurrentCell(false); + }, + { once: true } + ); + this._prevPortalAbortController = abortController; + const root = createLitPortal({ + abortController, + closeOnClickAway: true, + computePosition: { + referenceElement: this, + placement: 'bottom', + middleware: [offset(10), flip()], + }, + template: () => { + const datePicker = new DatePicker(); + datePicker.value = this.value ?? Date.now(); + datePicker.onChange = (date: Date) => { + this.tempValue = date; + }; + datePicker.onEscape = () => { + abortController.abort(); + }; + requestAnimationFrame(() => datePicker.focusDateCell()); + return datePicker; + }, + }); + // TODO: use z-index from variable, + // for now the slide-layout-modal's z-index is `1001` + // the z-index of popover should be higher than it + // root.style.zIndex = 'var(--affine-z-index-popover)'; + root.style.zIndex = '1002'; + }; + + private updateValue = () => { + const tempValue = this.tempValue; + if (!tempValue) { + return; + } + + this.onChange(tempValue.getTime()); + this.tempValue = undefined; + }; + + get dateString() { + const value = this.tempValue ?? this.value; + return value ? format(value, 'yyyy/MM/dd') : ''; + } + + override firstUpdated() { + this.openDatePicker(); + } + + override onExitEditMode() { + this.updateValue(); + this._prevPortalAbortController?.abort(); + } + + override render() { + return html`
+ ${this.dateString} +
`; + } + + @state() + accessor tempValue: Date | undefined = undefined; +} + +export const datePropertyConfig = datePropertyModelConfig.createPropertyMeta({ + icon: createIcon('DateTimeIcon'), + cellRenderer: { + view: createFromBaseCellRenderer(DateCell), + edit: createFromBaseCellRenderer(DateCellEditing), + }, +}); diff --git a/packages/affine/microsheet-data-view/src/property-presets/date/define.ts b/packages/affine/microsheet-data-view/src/property-presets/date/define.ts new file mode 100644 index 000000000000..6cebdd548ad5 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/property-presets/date/define.ts @@ -0,0 +1,19 @@ +import { tDate } from '../../core/logical/data-type.js'; +import { propertyType } from '../../core/property/property-config.js'; + +export const datePropertyType = propertyType('date'); +export const datePropertyModelConfig = datePropertyType.modelConfig({ + name: 'Date', + type: () => tDate.create(), + defaultData: () => ({}), + cellToString: data => data?.toString() ?? '', + cellFromString: data => { + const isDateFormat = !isNaN(Date.parse(data)); + + return { + value: isDateFormat ? +new Date(data) : null, + }; + }, + cellToJson: data => data ?? null, + isEmpty: data => data == null, +}); diff --git a/packages/affine/microsheet-data-view/src/property-presets/image/cell-renderer.ts b/packages/affine/microsheet-data-view/src/property-presets/image/cell-renderer.ts new file mode 100644 index 000000000000..6466a5f80ed6 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/property-presets/image/cell-renderer.ts @@ -0,0 +1,32 @@ +import { css, html } from 'lit'; + +import { BaseCellRenderer } from '../../core/property/index.js'; +import { createFromBaseCellRenderer } from '../../core/property/renderer.js'; +import { createIcon } from '../../core/utils/uni-icon.js'; +import { imagePropertyModelConfig } from './define.js'; + +export class TextCell extends BaseCellRenderer { + static override styles = css` + affine-microsheet-image-cell { + width: 100%; + height: 100%; + display: flex; + align-items: center; + } + affine-microsheet-image-cell img { + width: 20px; + height: 20px; + } + `; + + override render() { + return html``; + } +} + +export const imagePropertyConfig = imagePropertyModelConfig.createPropertyMeta({ + icon: createIcon('ImageIcon'), + cellRenderer: { + view: createFromBaseCellRenderer(TextCell), + }, +}); diff --git a/packages/affine/microsheet-data-view/src/property-presets/image/define.ts b/packages/affine/microsheet-data-view/src/property-presets/image/define.ts new file mode 100644 index 000000000000..2fb1600871c8 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/property-presets/image/define.ts @@ -0,0 +1,18 @@ +import { tImage } from '../../core/logical/data-type.js'; +import { propertyType } from '../../core/property/property-config.js'; + +export const imagePropertyType = propertyType('image'); + +export const imagePropertyModelConfig = imagePropertyType.modelConfig({ + name: 'image', + type: () => tImage.create(), + defaultData: () => ({}), + cellToString: data => data ?? '', + cellFromString: data => { + return { + value: data, + }; + }, + cellToJson: data => data ?? null, + isEmpty: data => data == null, +}); diff --git a/packages/affine/microsheet-data-view/src/property-presets/index.ts b/packages/affine/microsheet-data-view/src/property-presets/index.ts new file mode 100644 index 000000000000..b1b8b3d22748 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/property-presets/index.ts @@ -0,0 +1,22 @@ +import { checkboxPropertyConfig } from './checkbox/cell-renderer.js'; +import { datePropertyConfig } from './date/cell-renderer.js'; +import { imagePropertyConfig } from './image/cell-renderer.js'; +import { multiSelectPropertyConfig } from './multi-select/cell-renderer.js'; +import { numberPropertyConfig } from './number/cell-renderer.js'; +import { progressPropertyConfig } from './progress/cell-renderer.js'; +import { selectPropertyConfig } from './select/cell-renderer.js'; +import { textPropertyConfig } from './text/cell-renderer.js'; + +export * from './converts.js'; +export * from './number/types.js'; +export * from './select/define.js'; +export const propertyPresets = { + checkboxPropertyConfig, + datePropertyConfig, + imagePropertyConfig, + multiSelectPropertyConfig, + numberPropertyConfig, + progressPropertyConfig, + selectPropertyConfig, + textPropertyConfig, +}; diff --git a/packages/affine/microsheet-data-view/src/property-presets/multi-select/cell-renderer.ts b/packages/affine/microsheet-data-view/src/property-presets/multi-select/cell-renderer.ts new file mode 100644 index 000000000000..6e66a229e9c8 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/property-presets/multi-select/cell-renderer.ts @@ -0,0 +1,97 @@ +import { popupTargetFromElement } from '@blocksuite/affine-components/context-menu'; +import { html } from 'lit/static-html.js'; + +import type { SelectPropertyData } from '../select/define.js'; + +import { BaseCellRenderer } from '../../core/property/index.js'; +import { createFromBaseCellRenderer } from '../../core/property/renderer.js'; +import { + popTagSelect, + type SelectTag, +} from '../../core/utils/tags/multi-tag-select.js'; +import { createIcon } from '../../core/utils/uni-icon.js'; +import { multiSelectPropertyModelConfig } from './define.js'; + +export class MultiSelectCell extends BaseCellRenderer< + string[], + SelectPropertyData +> { + override render() { + return html` + + `; + } +} + +export class MultiSelectCellEditing extends BaseCellRenderer< + string[], + SelectPropertyData +> { + private popTagSelect = () => { + this._disposables.add({ + dispose: popTagSelect( + popupTargetFromElement( + this.querySelector('affine-microsheet-multi-tag-view') ?? this + ), + { + options: this._options, + onOptionsChange: this._onOptionsChange, + value: this._value, + onChange: this._onChange, + onComplete: this._editComplete, + minWidth: 400, + } + ), + }); + }; + + _editComplete = () => { + this.selectCurrentCell(false); + }; + + _onChange = (ids: string[]) => { + this.onChange(ids); + }; + + _onOptionsChange = (options: SelectTag[]) => { + this.property.dataUpdate(data => { + return { + ...data, + options, + }; + }); + }; + + get _options(): SelectTag[] { + return this.property.data$.value.options; + } + + get _value() { + return this.value ?? []; + } + + override firstUpdated() { + this.popTagSelect(); + } + + override render() { + return html` + + `; + } +} + +export const multiSelectPropertyConfig = + multiSelectPropertyModelConfig.createPropertyMeta({ + icon: createIcon('MultiSelectIcon'), + cellRenderer: { + view: createFromBaseCellRenderer(MultiSelectCell), + edit: createFromBaseCellRenderer(MultiSelectCellEditing), + }, + }); diff --git a/packages/affine/microsheet-data-view/src/property-presets/multi-select/define.ts b/packages/affine/microsheet-data-view/src/property-presets/multi-select/define.ts new file mode 100644 index 000000000000..a789dd64b3f0 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/property-presets/multi-select/define.ts @@ -0,0 +1,70 @@ +import { nanoid } from '@blocksuite/store'; + +import type { SelectTag } from '../../core/utils/tags/multi-tag-select.js'; +import type { SelectPropertyData } from '../select/define.js'; + +import { tTag } from '../../core/logical/data-type.js'; +import { tArray } from '../../core/logical/typesystem.js'; +import { propertyType } from '../../core/property/property-config.js'; +import { getTagColor } from '../../core/utils/tags/colors.js'; + +export const multiSelectPropertyType = propertyType('multi-select'); +export const multiSelectPropertyModelConfig = + multiSelectPropertyType.modelConfig({ + name: 'Multi-select', + type: data => tArray(tTag.create({ tags: data.options })), + defaultData: () => ({ + options: [], + }), + addGroup: (text, oldData) => { + return { + options: [ + ...(oldData.options ?? []), + { + id: nanoid(), + value: text, + color: getTagColor(), + }, + ], + }; + }, + formatValue: v => { + if (Array.isArray(v)) { + return v.filter(v => v != null); + } + return []; + }, + cellToString: (data, colData) => + data?.map(id => colData.options.find(v => v.id === id)?.value).join(','), + cellFromString: (data, colData) => { + const optionMap = Object.fromEntries( + colData.options.map(v => [v.value, v]) + ); + const optionNames = data + .split(',') + .map(v => v.trim()) + .filter(v => v); + + const value: string[] = []; + optionNames.forEach(name => { + if (!optionMap[name]) { + const newOption: SelectTag = { + id: nanoid(), + value: name, + color: getTagColor(), + }; + colData.options.push(newOption); + value.push(newOption.id); + } else { + value.push(optionMap[name].id); + } + }); + + return { + value, + data: colData, + }; + }, + cellToJson: data => data ?? null, + isEmpty: data => data == null || data.length === 0, + }); diff --git a/packages/affine/microsheet-data-view/src/property-presets/number/cell-renderer.ts b/packages/affine/microsheet-data-view/src/property-presets/number/cell-renderer.ts new file mode 100644 index 000000000000..ca3d9b7b471a --- /dev/null +++ b/packages/affine/microsheet-data-view/src/property-presets/number/cell-renderer.ts @@ -0,0 +1,194 @@ +import { IS_MAC } from '@blocksuite/global/env'; +import { baseTheme } from '@toeverything/theme'; +import { css, html, unsafeCSS } from 'lit'; +import { query } from 'lit/decorators.js'; + +import type { NumberPropertyDataType } from './types.js'; + +import { BaseCellRenderer } from '../../core/property/index.js'; +import { createFromBaseCellRenderer } from '../../core/property/renderer.js'; +import { stopPropagation } from '../../core/utils/event.js'; +import { createIcon } from '../../core/utils/uni-icon.js'; +import { numberPropertyModelConfig } from './define.js'; +import { + formatNumber, + type NumberFormat, + parseNumber, +} from './utils/formatter.js'; + +export class NumberCell extends BaseCellRenderer< + number, + NumberPropertyDataType +> { + static override styles = css` + affine-microsheet-number-cell { + display: block; + width: 100%; + } + + .affine-microsheet-number { + display: flex; + align-items: center; + justify-content: flex-end; + width: 100%; + padding: 0; + border: none; + font-family: ${unsafeCSS(baseTheme.fontSansFamily)}; + font-size: var(--data-view-cell-text-size); + line-height: var(--data-view-cell-text-line-height); + color: var(--affine-text-primary-color); + font-weight: 400; + background-color: transparent; + } + `; + + private _getFormattedString() { + const enableNewFormatting = + this.view.featureFlags$.value.enable_number_formatting; + const decimals = this.property.data$.value.decimal ?? 0; + const formatMode = (this.property.data$.value.format ?? + 'number') as NumberFormat; + return this.value + ? enableNewFormatting + ? formatNumber(this.value, formatMode, decimals) + : this.value.toString() + : ''; + } + + override render() { + return html`
+ ${this._getFormattedString()} +
`; + } +} + +export class NumberCellEditing extends BaseCellRenderer< + number, + NumberPropertyDataType +> { + static override styles = css` + affine-microsheet-number-cell-editing { + display: block; + width: 100%; + cursor: text; + } + + .affine-microsheet-number { + display: flex; + align-items: center; + width: 100%; + padding: 0; + border: none; + font-family: ${unsafeCSS(baseTheme.fontSansFamily)}; + font-size: var(--data-view-cell-text-size); + line-height: var(--data-view-cell-text-line-height); + color: var(--affine-text-primary-color); + font-weight: 400; + background-color: transparent; + text-align: right; + } + + .affine-microsheet-number:focus { + outline: none; + } + `; + + private _getFormattedString = (value: number) => { + const enableNewFormatting = + this.view.featureFlags$.value.enable_number_formatting; + const decimals = this.property.data$.value.decimal ?? 0; + const formatMode = (this.property.data$.value.format ?? + 'number') as NumberFormat; + return enableNewFormatting + ? formatNumber(value, formatMode, decimals) + : value.toString(); + }; + + private _keydown = (e: KeyboardEvent) => { + const ctrlKey = IS_MAC ? e.metaKey : e.ctrlKey; + + if (e.key.toLowerCase() === 'z' && ctrlKey) { + e.stopPropagation(); + return; + } + + if (e.key === 'Enter' && !e.isComposing) { + requestAnimationFrame(() => { + this.selectCurrentCell(false); + }); + } + }; + + private _setValue = (str: string = this._inputEle.value) => { + if (!str) { + this.onChange(undefined); + return; + } + + const enableNewFormatting = + this.view.featureFlags$.value.enable_number_formatting; + const value = enableNewFormatting ? parseNumber(str) : parseFloat(str); + if (isNaN(value)) { + this._inputEle.value = this.value + ? this._getFormattedString(this.value) + : ''; + return; + } + + this._inputEle.value = this._getFormattedString(value); + this.onChange(value); + }; + + focusEnd = () => { + const end = this._inputEle.value.length; + this._inputEle.focus(); + this._inputEle.setSelectionRange(end, end); + }; + + _blur() { + this.selectCurrentCell(false); + } + + _focus() { + if (!this.isEditing) { + this.selectCurrentCell(true); + } + } + + override firstUpdated() { + requestAnimationFrame(() => { + this.focusEnd(); + }); + } + + override onExitEditMode() { + this._setValue(); + } + + override render() { + const formatted = this.value ? this._getFormattedString(this.value) : ''; + + return html``; + } + + @query('input') + private accessor _inputEle!: HTMLInputElement; +} + +export const numberPropertyConfig = + numberPropertyModelConfig.createPropertyMeta({ + icon: createIcon('NumberIcon'), + cellRenderer: { + view: createFromBaseCellRenderer(NumberCell), + edit: createFromBaseCellRenderer(NumberCellEditing), + }, + }); diff --git a/packages/affine/microsheet-data-view/src/property-presets/number/define.ts b/packages/affine/microsheet-data-view/src/property-presets/number/define.ts new file mode 100644 index 000000000000..fe8d1bcf4275 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/property-presets/number/define.ts @@ -0,0 +1,24 @@ +import type { NumberPropertyDataType } from './types.js'; + +import { tNumber } from '../../core/logical/data-type.js'; +import { propertyType } from '../../core/property/property-config.js'; + +export const numberPropertyType = propertyType('number'); + +export const numberPropertyModelConfig = numberPropertyType.modelConfig< + number, + NumberPropertyDataType +>({ + name: 'Number', + type: () => tNumber.create(), + defaultData: () => ({ decimal: 0, format: 'number' }), + cellToString: data => data?.toString() ?? '', + cellFromString: data => { + const num = data ? Number(data) : NaN; + return { + value: isNaN(num) ? null : num, + }; + }, + cellToJson: data => data ?? null, + isEmpty: data => data == null, +}); diff --git a/packages/affine/microsheet-data-view/src/property-presets/number/index.ts b/packages/affine/microsheet-data-view/src/property-presets/number/index.ts new file mode 100644 index 000000000000..d4702960d547 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/property-presets/number/index.ts @@ -0,0 +1 @@ +export * from './types.js'; diff --git a/packages/affine/microsheet-data-view/src/property-presets/number/types.ts b/packages/affine/microsheet-data-view/src/property-presets/number/types.ts new file mode 100644 index 000000000000..f0f0f790a7ae --- /dev/null +++ b/packages/affine/microsheet-data-view/src/property-presets/number/types.ts @@ -0,0 +1,6 @@ +import type { NumberFormat } from './utils/formatter.js'; + +export type NumberPropertyDataType = { + decimal?: number; + format?: NumberFormat; +}; diff --git a/packages/affine/microsheet-data-view/src/property-presets/number/utils/formats.ts b/packages/affine/microsheet-data-view/src/property-presets/number/utils/formats.ts new file mode 100644 index 000000000000..e1437571484d --- /dev/null +++ b/packages/affine/microsheet-data-view/src/property-presets/number/utils/formats.ts @@ -0,0 +1,19 @@ +import type { NumberFormat } from './formatter.js'; + +export type NumberCellFormat = { + type: NumberFormat; + label: string; + symbol: string; // New property for symbol +}; + +export const numberFormats: NumberCellFormat[] = [ + { type: 'number', label: 'Number', symbol: '#' }, + { type: 'numberWithCommas', label: 'Number With Commas', symbol: '#' }, + { type: 'percent', label: 'Percent', symbol: '%' }, + { type: 'currencyYen', label: 'Japanese Yen', symbol: '¥' }, + { type: 'currencyCNY', label: 'Chinese Yuan', symbol: '¥' }, + { type: 'currencyINR', label: 'Indian Rupee', symbol: '₹' }, + { type: 'currencyUSD', label: 'US Dollar', symbol: '$' }, + { type: 'currencyEUR', label: 'Euro', symbol: '€' }, + { type: 'currencyGBP', label: 'British Pound', symbol: '£' }, +]; diff --git a/packages/affine/microsheet-data-view/src/property-presets/number/utils/formatter.ts b/packages/affine/microsheet-data-view/src/property-presets/number/utils/formatter.ts new file mode 100644 index 000000000000..4b418d5eea2f --- /dev/null +++ b/packages/affine/microsheet-data-view/src/property-presets/number/utils/formatter.ts @@ -0,0 +1,101 @@ +export type NumberFormat = + | 'number' + | 'numberWithCommas' + | 'percent' + | 'currencyYen' + | 'currencyINR' + | 'currencyCNY' + | 'currencyUSD' + | 'currencyEUR' + | 'currencyGBP'; + +const currency = (currency: string): Intl.NumberFormatOptions => ({ + style: 'currency', + currency, + currencyDisplay: 'symbol', +}); + +const numberFormatDefaultConfig: Record< + NumberFormat, + Intl.NumberFormatOptions +> = { + number: { style: 'decimal', useGrouping: false }, + numberWithCommas: { style: 'decimal', useGrouping: true }, + percent: { style: 'percent', useGrouping: false }, + currencyINR: currency('INR'), + currencyYen: currency('JPY'), + currencyCNY: currency('CNY'), + currencyUSD: currency('USD'), + currencyEUR: currency('EUR'), + currencyGBP: currency('GBP'), +}; + +export function formatNumber( + value: number, + format: NumberFormat, + decimals?: number +) { + const formatterOptions = { ...numberFormatDefaultConfig[format] }; + if (decimals !== undefined) { + // for feature flag should default to 0 after release + Object.assign(formatterOptions, { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }); + } + const formatter = new Intl.NumberFormat(navigator.language, formatterOptions); + return formatter.format(value); +} + +export function getLocaleDecimalSeparator(locale?: string) { + return (1.1).toLocaleString(locale ?? navigator.language).slice(1, 2); +} + +// Since we Intl does not provide a parse function we just made it ourself +export function parseNumber(value: string, decimalSeparator?: string): number { + decimalSeparator = decimalSeparator ?? getLocaleDecimalSeparator(); + + // Normalize decimal separator to a period for consistency + const normalizedValue = value.replace( + new RegExp(`\\${decimalSeparator}`, 'g'), + '.' + ); + + // Remove any leading and trailing non-numeric characters except valid signs, decimal points, and exponents + let sanitizedValue = normalizedValue.replace(/^[^\d-+eE.]+|[^\d]+$/g, ''); + + // Remove non-numeric characters except decimal points, exponents, and valid signs + sanitizedValue = sanitizedValue.replace(/[^0-9.eE+-]/g, ''); + + // Handle multiple signs: Keep only the first sign + sanitizedValue = sanitizedValue.replace(/([-+]){2,}/g, '$1'); + + // Handle misplaced signs: Keep only the leading sign and sign after 'e' or 'E' + sanitizedValue = sanitizedValue.replace( + /^([-+]?)[^eE]*([eE][-+]?\d+)?$/, + (_, p1, p2) => + p1 + + sanitizedValue.replace(/[eE].*/, '').replace(/[^\d.]/g, '') + + (p2 || '') + ); + + // Handle multiple decimal points: Keep only the first one in the main part + sanitizedValue = sanitizedValue.replace(/(\..*)\./g, '$1'); + + // If there is an 'e' or 'E', handle the scientific notation + if (/[eE]/.test(sanitizedValue)) { + const [base, exp] = sanitizedValue.split(/[eE]/); + if ( + !base || + !exp || + exp.includes('.') || + exp.includes('e') || + exp.includes('E') + ) { + return NaN; // Invalid scientific notation + } + return parseFloat(sanitizedValue); + } + + return parseFloat(sanitizedValue); +} diff --git a/packages/affine/microsheet-data-view/src/property-presets/progress/cell-renderer.ts b/packages/affine/microsheet-data-view/src/property-presets/progress/cell-renderer.ts new file mode 100644 index 000000000000..2e161150b1b4 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/property-presets/progress/cell-renderer.ts @@ -0,0 +1,223 @@ +import { css, html } from 'lit'; +import { query, state } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { BaseCellRenderer } from '../../core/property/index.js'; +import { createFromBaseCellRenderer } from '../../core/property/renderer.js'; +import { startDrag } from '../../core/utils/drag.js'; +import { createIcon } from '../../core/utils/uni-icon.js'; +import { progressPropertyModelConfig } from './define.js'; + +const styles = css` + affine-microsheet-progress-cell-editing { + display: block; + width: 100%; + padding: 0 4px; + } + + affine-microsheet-progress-cell { + display: block; + width: 100%; + padding: 0 4px; + } + + .affine-microsheet-progress { + display: flex; + align-items: center; + height: var(--data-view-cell-text-line-height); + gap: 4px; + } + + .affine-microsheet-progress-bar { + position: relative; + width: 104px; + } + + .affine-microsheet-progress-bg { + overflow: hidden; + width: 100%; + height: 10px; + border-radius: 22px; + } + + .affine-microsheet-progress-fg { + height: 100%; + } + + .affine-microsheet-progress-drag-handle { + position: absolute; + top: 0; + left: 0; + transform: translate(0px, -1px); + width: 6px; + height: 12px; + border-radius: 2px; + opacity: 1; + cursor: ew-resize; + background: var(--affine-primary-color); + transition: opacity 0.2s ease-in-out; + } + + .progress-number { + display: flex; + justify-content: center; + align-items: center; + height: 18px; + width: 25px; + color: var(--affine-text-secondary-color); + font-size: 14px; + } +`; + +const progressColors = { + empty: 'var(--affine-black-10)', + processing: 'var(--affine-processing-color)', + success: 'var(--affine-success-color)', +}; + +export class ProgressCell extends BaseCellRenderer { + static override styles = styles; + + protected override render() { + const progress = this.value ?? 0; + let backgroundColor = progressColors.processing; + if (progress === 100) { + backgroundColor = progressColors.success; + } + const fgStyles = styleMap({ + width: `${progress}%`, + backgroundColor, + }); + const bgStyles = styleMap({ + backgroundColor: + progress === 0 ? progressColors.empty : 'var(--affine-hover-color)', + }); + + return html`
+
+
+
+
+
+
${progress}
+
`; + } +} + +export class ProgressCellEditing extends BaseCellRenderer { + static override styles = styles; + + startDrag = (event: MouseEvent) => { + const bgRect = this._progressBg.getBoundingClientRect(); + const min = bgRect.left; + const max = bgRect.right; + const setValue = (x: number) => { + this.tempValue = Math.round( + ((Math.min(max, Math.max(min, x)) - min) / (max - min)) * 100 + ); + }; + startDrag(event, { + onDrag: ({ x }) => { + setValue(x); + return; + }, + onMove: ({ x }) => { + setValue(x); + return; + }, + onDrop: () => { + // + }, + onClear: () => { + // + }, + }); + }; + + get _value() { + return this.tempValue ?? this.value ?? 0; + } + + _onChange(value?: number) { + this.tempValue = value; + } + + override firstUpdated() { + const disposables = this._disposables; + + disposables.addFromEvent(this._progressBg, 'pointerdown', this.startDrag); + disposables.addFromEvent(window, 'keydown', evt => { + if (evt.key === 'ArrowDown') { + this._onChange(Math.max(0, this._value - 1)); + return; + } + if (evt.key === 'ArrowUp') { + this._onChange(Math.min(100, this._value + 1)); + return; + } + }); + } + + override onCopy(_e: ClipboardEvent) { + _e.preventDefault(); + } + + override onCut(_e: ClipboardEvent) { + _e.preventDefault(); + } + + override onExitEditMode() { + this.onChange(this._value); + } + + override onPaste(_e: ClipboardEvent) { + _e.preventDefault(); + } + + protected override render() { + const progress = this._value; + let backgroundColor = progressColors.processing; + if (progress === 100) { + backgroundColor = progressColors.success; + } + const fgStyles = styleMap({ + width: `${progress}%`, + backgroundColor, + }); + const bgStyles = styleMap({ + backgroundColor: + progress === 0 ? progressColors.empty : 'var(--affine-hover-color)', + }); + const handleStyles = styleMap({ + left: `calc(${progress}% - 3px)`, + }); + + return html`
+
+
+
+
+
+
+
${progress}
+
`; + } + + @query('.affine-microsheet-progress-bg') + private accessor _progressBg!: HTMLElement; + + @state() + private accessor tempValue: number | undefined = undefined; +} + +export const progressPropertyConfig = + progressPropertyModelConfig.createPropertyMeta({ + icon: createIcon('ProgressIcon'), + cellRenderer: { + view: createFromBaseCellRenderer(ProgressCell), + edit: createFromBaseCellRenderer(ProgressCellEditing), + }, + }); diff --git a/packages/affine/microsheet-data-view/src/property-presets/progress/define.ts b/packages/affine/microsheet-data-view/src/property-presets/progress/define.ts new file mode 100644 index 000000000000..f7e57e2ede64 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/property-presets/progress/define.ts @@ -0,0 +1,20 @@ +import { tNumber } from '../../core/logical/data-type.js'; +import { propertyType } from '../../core/property/property-config.js'; + +export const progressPropertyType = propertyType('progress'); + +export const progressPropertyModelConfig = + progressPropertyType.modelConfig({ + name: 'Progress', + type: () => tNumber.create(), + defaultData: () => ({}), + cellToString: data => data?.toString() ?? '', + cellFromString: data => { + const num = data ? Number(data) : NaN; + return { + value: isNaN(num) ? null : num, + }; + }, + cellToJson: data => data ?? null, + isEmpty: () => false, + }); diff --git a/packages/affine/microsheet-data-view/src/property-presets/pure-index.ts b/packages/affine/microsheet-data-view/src/property-presets/pure-index.ts new file mode 100644 index 000000000000..3987e75fc8f8 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/property-presets/pure-index.ts @@ -0,0 +1,19 @@ +import { checkboxPropertyModelConfig } from './checkbox/define.js'; +import { datePropertyModelConfig } from './date/define.js'; +import { imagePropertyModelConfig } from './image/define.js'; +import { multiSelectPropertyModelConfig } from './multi-select/define.js'; +import { numberPropertyModelConfig } from './number/define.js'; +import { progressPropertyModelConfig } from './progress/define.js'; +import { selectPropertyModelConfig } from './select/define.js'; +import { textPropertyModelConfig } from './text/define.js'; + +export const propertyModelPresets = { + checkboxPropertyModelConfig, + datePropertyModelConfig, + imagePropertyModelConfig, + multiSelectPropertyModelConfig, + numberPropertyModelConfig, + progressPropertyModelConfig, + selectPropertyModelConfig, + textPropertyModelConfig, +}; diff --git a/packages/affine/microsheet-data-view/src/property-presets/select/cell-renderer.ts b/packages/affine/microsheet-data-view/src/property-presets/select/cell-renderer.ts new file mode 100644 index 000000000000..f45e9f884b3b --- /dev/null +++ b/packages/affine/microsheet-data-view/src/property-presets/select/cell-renderer.ts @@ -0,0 +1,98 @@ +import { popupTargetFromElement } from '@blocksuite/affine-components/context-menu'; +import { html } from 'lit/static-html.js'; + +import { BaseCellRenderer } from '../../core/property/index.js'; +import { createFromBaseCellRenderer } from '../../core/property/renderer.js'; +import { + popTagSelect, + type SelectTag, +} from '../../core/utils/tags/multi-tag-select.js'; +import { createIcon } from '../../core/utils/uni-icon.js'; +import { + type SelectPropertyData, + selectPropertyModelConfig, +} from './define.js'; + +export class SelectCell extends BaseCellRenderer { + override render() { + const value = this.value ? [this.value] : []; + return html` + + `; + } +} + +export class SelectCellEditing extends BaseCellRenderer< + string, + SelectPropertyData +> { + private popTagSelect = () => { + this._disposables.add({ + dispose: popTagSelect( + popupTargetFromElement( + this.querySelector('affine-microsheet-multi-tag-view') ?? this + ), + { + mode: 'single', + options: this._options, + onOptionsChange: this._onOptionsChange, + value: this._value, + onChange: this._onChange, + onComplete: this._editComplete, + minWidth: 400, + } + ), + }); + }; + + _editComplete = () => { + this.selectCurrentCell(false); + }; + + _onChange = ([id]: string[]) => { + this.onChange(id); + }; + + _onOptionsChange = (options: SelectTag[]) => { + this.property.dataUpdate(data => { + return { + ...data, + options, + }; + }); + }; + + get _options(): SelectTag[] { + return this.property.data$.value.options; + } + + get _value() { + const value = this.value; + return value ? [value] : []; + } + + override firstUpdated() { + this.popTagSelect(); + } + + override render() { + return html` + + `; + } +} + +export const selectPropertyConfig = + selectPropertyModelConfig.createPropertyMeta({ + icon: createIcon('SingleSelectIcon'), + cellRenderer: { + view: createFromBaseCellRenderer(SelectCell), + edit: createFromBaseCellRenderer(SelectCellEditing), + }, + }); diff --git a/packages/affine/microsheet-data-view/src/property-presets/select/define.ts b/packages/affine/microsheet-data-view/src/property-presets/select/define.ts new file mode 100644 index 000000000000..e7d53b583df8 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/property-presets/select/define.ts @@ -0,0 +1,66 @@ +import { nanoid } from '@blocksuite/store'; + +import type { SelectTag } from '../../core/utils/tags/multi-tag-select.js'; + +import { tTag } from '../../core/logical/data-type.js'; +import { propertyType } from '../../core/property/property-config.js'; +import { getTagColor } from '../../core/utils/tags/colors.js'; + +export const selectPropertyType = propertyType('select'); + +export type SelectPropertyData = { + options: SelectTag[]; +}; +export const selectPropertyModelConfig = selectPropertyType.modelConfig< + string, + SelectPropertyData +>({ + name: 'Select', + type: data => tTag.create({ tags: data.options }), + defaultData: () => ({ + options: [], + }), + addGroup: (text, oldData) => { + return { + options: [ + ...(oldData.options ?? []), + { id: nanoid(), value: text, color: getTagColor() }, + ], + }; + }, + cellToString: (data, colData) => + colData.options.find(v => v.id === data)?.value ?? '', + cellFromString: (data, colData) => { + if (!data) { + return { value: null, data: colData }; + } + const optionMap = Object.fromEntries( + colData.options.map(v => [v.value, v]) + ); + const name = data + .split(',') + .map(v => v.trim()) + .filter(v => v)[0]; + + let value = null; + const option = optionMap[name]; + if (!option) { + const newOption: SelectTag = { + id: nanoid(), + value: name, + color: getTagColor(), + }; + colData.options.push(newOption); + value = newOption.id; + } else { + value = option.id; + } + + return { + value, + data: colData, + }; + }, + cellToJson: data => data ?? null, + isEmpty: data => data == null, +}); diff --git a/packages/affine/microsheet-data-view/src/property-presets/text/cell-renderer.ts b/packages/affine/microsheet-data-view/src/property-presets/text/cell-renderer.ts new file mode 100644 index 000000000000..99223a2c3b65 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/property-presets/text/cell-renderer.ts @@ -0,0 +1,120 @@ +import { baseTheme } from '@toeverything/theme'; +import { css, html, unsafeCSS } from 'lit'; +import { query } from 'lit/decorators.js'; + +import { BaseCellRenderer } from '../../core/property/index.js'; +import { createFromBaseCellRenderer } from '../../core/property/renderer.js'; +import { createIcon } from '../../core/utils/uni-icon.js'; +import { textPropertyModelConfig } from './define.js'; + +export class TextCell extends BaseCellRenderer { + static override styles = css` + affine-microsheet-text-cell { + display: block; + width: 100%; + height: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .affine-microsheet-text { + display: flex; + align-items: center; + height: 100%; + width: 100%; + padding: 0; + border: none; + font-family: ${unsafeCSS(baseTheme.fontSansFamily)}; + font-size: var(--affine-font-base); + line-height: var(--affine-line-height); + color: var(--affine-text-primary-color); + font-weight: 400; + background-color: transparent; + } + `; + + override render() { + return html`
${this.value ?? ''}
`; + } +} +export class TextCellEditing extends BaseCellRenderer { + static override styles = css` + affine-microsheet-text-cell-editing { + display: block; + width: 100%; + height: 100%; + cursor: text; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .affine-microsheet-text { + display: flex; + align-items: center; + height: 100%; + width: 100%; + padding: 0; + border: none; + font-family: ${unsafeCSS(baseTheme.fontSansFamily)}; + font-size: var(--affine-font-base); + line-height: var(--affine-line-height); + color: var(--affine-text-primary-color); + font-weight: 400; + background-color: transparent; + } + + .affine-microsheet-text:focus { + outline: none; + } + `; + + private _keydown = (e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.isComposing) { + this._setValue(); + setTimeout(() => { + this.selectCurrentCell(false); + }); + } + }; + + private _setValue = (str: string = this._inputEle.value) => { + this._inputEle.value = `${this.value ?? ''}`; + this.onChange(str); + }; + + focusEnd = () => { + const end = this._inputEle.value.length; + this._inputEle.focus(); + this._inputEle.setSelectionRange(end, end); + }; + + override firstUpdated() { + this.focusEnd(); + } + + override onExitEditMode() { + this._setValue(); + } + + override render() { + return html``; + } + + @query('input') + private accessor _inputEle!: HTMLInputElement; +} + +export const textPropertyConfig = textPropertyModelConfig.createPropertyMeta({ + icon: createIcon('TextIcon'), + + cellRenderer: { + view: createFromBaseCellRenderer(TextCell), + edit: createFromBaseCellRenderer(TextCellEditing), + }, +}); diff --git a/packages/affine/microsheet-data-view/src/property-presets/text/define.ts b/packages/affine/microsheet-data-view/src/property-presets/text/define.ts new file mode 100644 index 000000000000..258390ac4323 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/property-presets/text/define.ts @@ -0,0 +1,18 @@ +import { tString } from '../../core/logical/data-type.js'; +import { propertyType } from '../../core/property/property-config.js'; + +export const textPropertyType = propertyType('text'); + +export const textPropertyModelConfig = textPropertyType.modelConfig({ + name: 'Plain-Text', + type: () => tString.create(), + defaultData: () => ({}), + cellToString: data => data ?? '', + cellFromString: data => { + return { + value: data, + }; + }, + cellToJson: data => data ?? null, + isEmpty: data => data == null || data.length === 0, +}); diff --git a/packages/affine/microsheet-data-view/src/view-presets/convert.ts b/packages/affine/microsheet-data-view/src/view-presets/convert.ts new file mode 100644 index 000000000000..19e57fa2108a --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/convert.ts @@ -0,0 +1,21 @@ +import { createViewConvert } from '../core/view/convert.js'; +import { kanbanViewModel } from './kanban/index.js'; +import { tableViewModel } from './table/index.js'; + +export const viewConverts = [ + createViewConvert(tableViewModel, kanbanViewModel, data => { + if (data.groupBy) { + return { + filter: data.filter, + groupBy: data.groupBy, + }; + } + return { + filter: data.filter, + }; + }), + createViewConvert(kanbanViewModel, tableViewModel, data => ({ + filter: data.filter, + groupBy: data.groupBy, + })), +]; diff --git a/packages/affine/microsheet-data-view/src/view-presets/index.ts b/packages/affine/microsheet-data-view/src/view-presets/index.ts new file mode 100644 index 000000000000..b4b46ce58b80 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/index.ts @@ -0,0 +1,11 @@ +import { kanbanViewMeta } from './kanban/index.js'; +import { tableViewMeta } from './table/index.js'; + +export * from './convert.js'; +export * from './kanban/index.js'; +export * from './table/index.js'; + +export const viewPresets = { + tableViewMeta: tableViewMeta, + kanbanViewMeta: kanbanViewMeta, +}; diff --git a/packages/affine/microsheet-data-view/src/view-presets/kanban/card.ts b/packages/affine/microsheet-data-view/src/view-presets/kanban/card.ts new file mode 100644 index 000000000000..9336b3422daf --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/kanban/card.ts @@ -0,0 +1,333 @@ +import { popupTargetFromElement } from '@blocksuite/affine-components/context-menu'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { CenterPeekIcon, MoreHorizontalIcon } from '@blocksuite/icons/lit'; +import { css } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { html } from 'lit/static-html.js'; + +import type { DataViewRenderer } from '../../core/data-view.js'; +import type { KanbanColumn, KanbanSingleView } from './kanban-view-manager.js'; + +import { openDetail, popCardMenu } from './menu.js'; + +const styles = css` + affine-microsheet-data-view-kanban-card { + display: flex; + position: relative; + flex-direction: column; + border: 1px solid var(--affine-border-color); + box-shadow: 0px 2px 3px 0px rgba(0, 0, 0, 0.05); + border-radius: 8px; + transition: background-color 100ms ease-in-out; + background-color: var(--affine-background-kanban-card-color); + } + + affine-microsheet-data-view-kanban-card:hover { + background-color: var(--affine-hover-color); + } + + affine-microsheet-data-view-kanban-card .card-header { + padding: 8px; + display: flex; + flex-direction: column; + gap: 8px; + } + + affine-microsheet-data-view-kanban-card + .card-header-title + microsheet-uni-lit { + width: 100%; + } + + .card-header.has-divider { + border-bottom: 0.5px solid var(--affine-border-color); + } + + affine-microsheet-data-view-kanban-card .card-header-title { + font-size: var(--data-view-cell-text-size); + line-height: var(--data-view-cell-text-line-height); + } + + affine-microsheet-data-view-kanban-card .card-header-icon { + padding: 4px; + background-color: var(--affine-background-secondary-color); + display: flex; + align-items: center; + border-radius: 4px; + width: max-content; + } + + affine-microsheet-data-view-kanban-card .card-header-icon svg { + width: 16px; + height: 16px; + fill: var(--affine-icon-color); + color: var(--affine-icon-color); + } + + affine-microsheet-data-view-kanban-card .card-body { + display: flex; + flex-direction: column; + padding: 8px; + gap: 4px; + } + + affine-microsheet-data-view-kanban-card:hover .card-ops { + visibility: visible; + } + + .card-ops { + position: absolute; + right: 8px; + top: 8px; + visibility: hidden; + display: flex; + gap: 4px; + cursor: pointer; + } + + .card-op { + display: flex; + position: relative; + padding: 4px; + border-radius: 4px; + box-shadow: 0px 0px 4px 0px rgba(66, 65, 73, 0.14); + background-color: var(--affine-background-primary-color); + } + + .card-op:hover:before { + content: ''; + border-radius: 4px; + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + background-color: var(--affine-hover-color); + } + + .card-op svg { + fill: var(--affine-icon-color); + color: var(--affine-icon-color); + width: 16px; + height: 16px; + } +`; + +export class KanbanCard extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = styles; + + private clickEdit = (e: MouseEvent) => { + e.stopPropagation(); + const selection = this.getSelection(); + if (selection) { + openDetail(this.dataViewEle, this.cardId, selection); + } + }; + + private clickMore = (e: MouseEvent) => { + e.stopPropagation(); + const selection = this.getSelection(); + const ele = e.currentTarget as HTMLElement; + if (selection) { + selection.selection = { + selectionType: 'card', + cards: [ + { + groupKey: this.groupKey, + cardId: this.cardId, + }, + ], + }; + popCardMenu( + this.dataViewEle, + popupTargetFromElement(ele), + this.cardId, + selection + ); + } + }; + + private contextMenu = (e: MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + const selection = this.getSelection(); + if (selection) { + selection.selection = { + selectionType: 'card', + cards: [ + { + groupKey: this.groupKey, + cardId: this.cardId, + }, + ], + }; + const target = e.target as HTMLElement; + const ref = + target.closest('affine-microsheet-data-view-kanban-cell') ?? this; + popCardMenu( + this.dataViewEle, + popupTargetFromElement(ref), + this.cardId, + selection + ); + } + }; + + private getSelection() { + return this.closest('affine-microsheet-data-view-kanban') + ?.selectionController; + } + + private renderBody(columns: KanbanColumn[]) { + if (columns.length === 0) { + return ''; + } + return html`
+ ${repeat( + columns, + v => v.id, + column => { + if (this.view.isInHeader(column.id)) { + return ''; + } + return html` `; + } + )} +
`; + } + + private renderHeader(columns: KanbanColumn[]) { + if (!this.view.hasHeader(this.cardId)) { + return ''; + } + const classList = classMap({ + 'card-header': true, + 'has-divider': columns.length > 0, + }); + return html` +
${this.renderTitle()} ${this.renderIcon()}
+ `; + } + + private renderIcon() { + const icon = this.view.getHeaderIcon(this.cardId); + if (!icon) { + return; + } + return html`
+ ${icon.cellGet(this.cardId).value$.value} +
`; + } + + private renderOps() { + if (this.view.readonly$.value) { + return; + } + return html` +
+
+ ${CenterPeekIcon()} +
+
+ ${MoreHorizontalIcon()} +
+
+ `; + } + + private renderTitle() { + const title = this.view.getHeaderTitle(this.cardId); + if (!title) { + return; + } + return html`
+ +
`; + } + + override connectedCallback() { + super.connectedCallback(); + if (this.view.readonly$.value) { + return; + } + this._disposables.addFromEvent(this, 'contextmenu', e => { + this.contextMenu(e); + }); + this._disposables.addFromEvent(this, 'click', e => { + if (e.shiftKey) { + this.getSelection()?.shiftClickCard(e); + return; + } + const selection = this.getSelection(); + const preSelection = selection?.selection; + + if (preSelection?.selectionType !== 'card') return; + + if (selection) { + selection.selection = undefined; + } + this.dataViewEle.openDetailPanel({ + view: this.view, + rowId: this.cardId, + onClose: () => { + if (selection) { + selection.selection = preSelection; + } + }, + }); + }); + } + + override render() { + const columns = this.view.properties$.value.filter( + v => !this.view.isInHeader(v.id) + ); + this.style.border = this.isFocus + ? '1px solid var(--affine-primary-color)' + : ''; + return html` + ${this.renderHeader(columns)} ${this.renderBody(columns)} + ${this.renderOps()} + `; + } + + @property({ attribute: false }) + accessor cardId!: string; + + @property({ attribute: false }) + accessor dataViewEle!: DataViewRenderer; + + @property({ attribute: false }) + accessor groupKey!: string; + + @state() + accessor isFocus = false; + + @property({ attribute: false }) + accessor view!: KanbanSingleView; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-microsheet-data-view-kanban-card': KanbanCard; + } +} diff --git a/packages/affine/microsheet-data-view/src/view-presets/kanban/cell.ts b/packages/affine/microsheet-data-view/src/view-presets/kanban/cell.ts new file mode 100644 index 000000000000..ae5a9d9a7fd2 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/kanban/cell.ts @@ -0,0 +1,191 @@ +// related component + +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { css } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { createRef } from 'lit/directives/ref.js'; +import { html } from 'lit/static-html.js'; + +import type { + CellRenderProps, + DataViewCellLifeCycle, +} from '../../core/property/index.js'; +import type { Property } from '../../core/view-manager/property.js'; +import type { KanbanSingleView } from './kanban-view-manager.js'; +import type { KanbanViewSelection } from './types.js'; + +import { renderUniLit } from '../../core/utils/uni-component/uni-component.js'; + +const styles = css` + affine-microsheet-data-view-kanban-cell { + border-radius: 4px; + display: flex; + align-items: center; + padding: 4px; + min-height: 20px; + border: 1px solid transparent; + box-sizing: border-box; + } + + affine-microsheet-data-view-kanban-cell:hover { + background-color: var(--affine-hover-color); + } + + affine-microsheet-data-view-kanban-cell .icon { + display: flex; + align-items: center; + justify-content: center; + align-self: start; + margin-right: 12px; + height: var(--data-view-cell-text-line-height); + } + + affine-microsheet-data-view-kanban-cell .icon svg { + width: 16px; + height: 16px; + fill: var(--affine-icon-color); + color: var(--affine-icon-color); + } + + .kanban-cell { + flex: 1; + display: block; + width: 196px; + } +`; + +export class KanbanCell extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = styles; + + private _cell = createRef(); + + selectCurrentCell = (editing: boolean) => { + const selectionView = this.closest( + 'affine-microsheet-data-view-kanban' + )?.selectionController; + if (!selectionView) return; + if (selectionView) { + const selection = selectionView.selection; + if (selection && this.isSelected(selection) && editing) { + selectionView.selection = { + selectionType: 'cell', + groupKey: this.groupKey, + cardId: this.cardId, + columnId: this.column.id, + isEditing: true, + }; + } else { + selectionView.selection = { + selectionType: 'cell', + groupKey: this.groupKey, + cardId: this.cardId, + columnId: this.column.id, + isEditing: false, + }; + } + } + }; + + get cell(): DataViewCellLifeCycle | undefined { + return this._cell.value; + } + + get selection() { + return this.closest('affine-microsheet-data-view-kanban') + ?.selectionController; + } + + override connectedCallback() { + super.connectedCallback(); + this._disposables.addFromEvent(this, 'click', e => { + if (e.shiftKey) { + return; + } + e.stopPropagation(); + const selectionElement = this.closest( + 'affine-microsheet-data-view-kanban' + )?.selectionController; + if (!selectionElement) return; + if (e.shiftKey) return; + + if (!this.editing) { + this.selectCurrentCell(!this.column.readonly$.value); + } + }); + } + + isSelected(selection: KanbanViewSelection) { + if ( + selection.selectionType !== 'cell' || + selection.groupKey !== this.groupKey + ) { + return; + } + return ( + selection.cardId === this.cardId && selection.columnId === this.column.id + ); + } + + override render() { + const props: CellRenderProps = { + cell: this.column.cellGet(this.cardId), + isEditing: this.editing, + selectCurrentCell: this.selectCurrentCell, + }; + const renderer = this.column.renderer$.value; + if (!renderer) return; + const { view, edit } = renderer; + this.style.border = this.isFocus + ? '1px solid var(--affine-primary-color)' + : ''; + this.style.boxShadow = this.editing + ? '0px 0px 0px 2px rgba(30, 150, 235, 0.30)' + : ''; + return html` ${this.renderIcon()} + ${renderUniLit(this.editing && edit ? edit : view, props, { + ref: this._cell, + class: 'kanban-cell', + style: { display: 'block', flex: '1', overflow: 'hidden' }, + })}`; + } + + renderIcon() { + if (this.contentOnly) { + return; + } + return html` `; + } + + @property({ attribute: false }) + accessor cardId!: string; + + @property({ attribute: false }) + accessor column!: Property; + + @property({ attribute: false }) + accessor contentOnly = false; + + @state() + accessor editing = false; + + @property({ attribute: false }) + accessor groupKey!: string; + + @state() + accessor isFocus = false; + + @property({ attribute: false }) + accessor view!: KanbanSingleView; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-microsheet-data-view-kanban-cell': KanbanCell; + } +} diff --git a/packages/affine/microsheet-data-view/src/view-presets/kanban/controller/clipboard.ts b/packages/affine/microsheet-data-view/src/view-presets/kanban/controller/clipboard.ts new file mode 100644 index 000000000000..d9ebb08fe980 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/kanban/controller/clipboard.ts @@ -0,0 +1,49 @@ +import type { UIEventStateContext } from '@blocksuite/block-std'; +import type { ReactiveController } from 'lit'; + +import type { DataViewKanban } from '../kanban-view.js'; +import type { KanbanViewSelectionWithType } from '../types.js'; + +export class KanbanClipboardController implements ReactiveController { + private _onCopy = ( + _context: UIEventStateContext, + _kanbanSelection: KanbanViewSelectionWithType + ) => { + // todo + return true; + }; + + private _onPaste = (_context: UIEventStateContext) => { + // todo + return true; + }; + + private get readonly() { + return this.host.props.view.readonly$.value; + } + + constructor(public host: DataViewKanban) { + host.addController(this); + } + + hostConnected() { + this.host.disposables.add( + this.host.props.handleEvent('copy', ctx => { + const kanbanSelection = this.host.selectionController.selection; + if (!kanbanSelection) return false; + + this._onCopy(ctx, kanbanSelection); + return true; + }) + ); + + this.host.disposables.add( + this.host.props.handleEvent('paste', ctx => { + if (this.readonly) return false; + + this._onPaste(ctx); + return true; + }) + ); + } +} diff --git a/packages/affine/microsheet-data-view/src/view-presets/kanban/controller/drag.ts b/packages/affine/microsheet-data-view/src/view-presets/kanban/controller/drag.ts new file mode 100644 index 000000000000..8927188508dc --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/kanban/controller/drag.ts @@ -0,0 +1,241 @@ +import type { InsertToPosition } from '@blocksuite/affine-shared/utils'; +import type { ReactiveController } from 'lit'; + +import { assertExists, Point, Rect } from '@blocksuite/global/utils'; + +import type { DataViewKanban } from '../kanban-view.js'; + +import { startDrag } from '../../../core/utils/drag.js'; +import { autoScrollOnBoundary } from '../../../core/utils/frame-loop.js'; +import { KanbanCard } from '../card.js'; +import { KanbanGroup } from '../group.js'; + +export class KanbanDragController implements ReactiveController { + dragStart = (ele: KanbanCard, evt: PointerEvent) => { + const eleRect = ele.getBoundingClientRect(); + const offsetLeft = evt.x - eleRect.left; + const offsetTop = evt.y - eleRect.top; + const preview = createDragPreview( + ele, + evt.x - offsetLeft, + evt.y - offsetTop + ); + const currentGroup = ele.closest( + 'affine-microsheet-data-view-kanban-group' + ); + const cancelScroll = autoScrollOnBoundary(this.scrollContainer); + startDrag< + | { type: 'out'; callback: () => void } + | { + type: 'self'; + key: string; + position: InsertToPosition; + } + | undefined, + PointerEvent + >(evt, { + onDrag: () => undefined, + onMove: evt => { + if (!(evt.target instanceof HTMLElement)) { + return; + } + preview.display(evt.x - offsetLeft, evt.y - offsetTop); + if (!Rect.fromDOM(this.host).isPointIn(Point.from(evt))) { + const callback = this.host.props.onDrag; + if (callback) { + this.dropPreview.remove(); + return { + type: 'out', + callback: callback(evt, ele.cardId), + }; + } + return; + } + const result = this.shooIndicator(evt, ele); + if (result) { + return { + type: 'self', + key: result.group.group.key, + position: result.position, + }; + } + return; + }, + onClear: () => { + preview.remove(); + this.dropPreview.remove(); + cancelScroll(); + }, + onDrop: result => { + if (!result) { + return; + } + if (result.type === 'out') { + result.callback(); + return; + } + if (result && currentGroup) { + currentGroup.group.manager.moveCardTo( + ele.cardId, + currentGroup.group.key, + result.key, + result.position + ); + } + }, + }); + }; + + dropPreview = createDropPreview(); + + getInsertPosition = ( + evt: MouseEvent + ): + | { group: KanbanGroup; card?: KanbanCard; position: InsertToPosition } + | undefined => { + const eles = document.elementsFromPoint(evt.x, evt.y); + const target = eles.find(v => v instanceof KanbanGroup) as KanbanGroup; + if (target) { + const card = getCardByPoint(target, evt.y); + return { + group: target, + card, + position: card + ? { + before: true, + id: card.cardId, + } + : 'end', + }; + } else { + return; + } + }; + + shooIndicator = ( + evt: MouseEvent, + self: KanbanCard | undefined + ): { group: KanbanGroup; position: InsertToPosition } | undefined => { + const position = this.getInsertPosition(evt); + if (position) { + this.dropPreview.display(position.group, self, position.card); + } else { + this.dropPreview.remove(); + } + return position; + }; + + get scrollContainer() { + const scrollContainer = this.host.querySelector( + '.affine-microsheet-data-view-kanban-groups' + ) as HTMLElement; + assertExists(scrollContainer); + return scrollContainer; + } + + constructor(private host: DataViewKanban) { + this.host.addController(this); + } + + hostConnected() { + if (this.host.props.view.readonly$.value) { + return; + } + this.host.disposables.add( + this.host.props.handleEvent('dragStart', context => { + const event = context.get('pointerState').raw; + const target = event.target; + if (target instanceof Element) { + const cell = target.closest( + 'affine-microsheet-data-view-kanban-cell' + ); + if (cell?.editing) { + return; + } + cell?.selectCurrentCell(false); + const card = target.closest( + 'affine-microsheet-data-view-kanban-card' + ); + if (card) { + this.dragStart(card, event); + } + } + return true; + }) + ); + } +} + +const createDragPreview = (card: KanbanCard, x: number, y: number) => { + const preOpacity = card.style.opacity; + card.style.opacity = '0.5'; + const div = document.createElement('div'); + const kanbanCard = new KanbanCard(); + kanbanCard.cardId = card.cardId; + kanbanCard.view = card.view; + kanbanCard.isFocus = true; + kanbanCard.style.backgroundColor = 'var(--affine-background-primary-color)'; + div.append(kanbanCard); + div.className = 'with-data-view-css-variable'; + div.style.width = `${card.getBoundingClientRect().width}px`; + div.style.position = 'fixed'; + // div.style.pointerEvents = 'none'; + div.style.transform = 'rotate(-3deg)'; + div.style.left = `${x}px`; + div.style.top = `${y}px`; + div.style.zIndex = '9999'; + document.body.append(div); + return { + display(x: number, y: number) { + div.style.left = `${Math.round(x)}px`; + div.style.top = `${Math.round(y)}px`; + }, + remove() { + card.style.opacity = preOpacity; + div.remove(); + }, + }; +}; +const createDropPreview = () => { + const div = document.createElement('div'); + div.style.height = '2px'; + div.style.borderRadius = '1px'; + div.style.backgroundColor = 'var(--affine-primary-color)'; + div.style.boxShadow = '0px 0px 8px 0px rgba(30, 150, 235, 0.35)'; + return { + display( + group: KanbanGroup, + self: KanbanCard | undefined, + card?: KanbanCard + ) { + const target = card ?? group.querySelector('.add-card'); + assertExists(target); + if (target.previousElementSibling === self || target === self) { + div.remove(); + return; + } + if (target.previousElementSibling === div) { + return; + } + target.insertAdjacentElement('beforebegin', div); + }, + remove() { + div.remove(); + }, + }; +}; + +const getCardByPoint = ( + group: KanbanGroup, + y: number +): KanbanCard | undefined => { + const cards = Array.from( + group.querySelectorAll('affine-microsheet-data-view-kanban-card') + ); + const positions = cards.map(v => { + const rect = v.getBoundingClientRect(); + return (rect.top + rect.bottom) / 2; + }); + const index = positions.findIndex(v => v > y); + return cards[index]; +}; diff --git a/packages/affine/microsheet-data-view/src/view-presets/kanban/controller/hotkeys.ts b/packages/affine/microsheet-data-view/src/view-presets/kanban/controller/hotkeys.ts new file mode 100644 index 000000000000..75bc90f70655 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/kanban/controller/hotkeys.ts @@ -0,0 +1,65 @@ +import type { ReactiveController } from 'lit'; + +import type { DataViewKanban } from '../kanban-view.js'; + +export class KanbanHotkeysController implements ReactiveController { + private get hasSelection() { + return !!this.host.selectionController.selection; + } + + constructor(private host: DataViewKanban) { + this.host.addController(this); + } + + hostConnected() { + this.host.disposables.add( + this.host.props.bindHotkey({ + Escape: () => { + this.host.selectionController.focusOut(); + return true; + }, + Enter: () => { + this.host.selectionController.focusIn(); + }, + ArrowUp: context => { + if (!this.hasSelection) return false; + + this.host.selectionController.focusNext('up'); + context.get('keyboardState').raw.preventDefault(); + return true; + }, + ArrowDown: context => { + if (!this.hasSelection) return false; + + this.host.selectionController.focusNext('down'); + context.get('keyboardState').raw.preventDefault(); + return true; + }, + Tab: context => { + if (!this.hasSelection) return false; + + this.host.selectionController.focusNext('down'); + context.get('keyboardState').raw.preventDefault(); + return true; + }, + ArrowLeft: context => { + if (!this.hasSelection) return false; + + this.host.selectionController.focusNext('left'); + context.get('keyboardState').raw.preventDefault(); + return true; + }, + ArrowRight: context => { + if (!this.hasSelection) return false; + + this.host.selectionController.focusNext('right'); + context.get('keyboardState').raw.preventDefault(); + return true; + }, + Backspace: () => { + this.host.selectionController.deleteCard(); + }, + }) + ); + } +} diff --git a/packages/affine/microsheet-data-view/src/view-presets/kanban/controller/selection.ts b/packages/affine/microsheet-data-view/src/view-presets/kanban/controller/selection.ts new file mode 100644 index 000000000000..7538ed6448fb --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/kanban/controller/selection.ts @@ -0,0 +1,750 @@ +import type { ReactiveController } from 'lit'; + +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import { assertExists } from '@blocksuite/global/utils'; + +import type { KanbanGroup } from '../group.js'; +import type { DataViewKanban } from '../kanban-view.js'; +import type { + KanbanCardSelection, + KanbanCardSelectionCard, + KanbanCellSelection, + KanbanGroupSelection, + KanbanViewSelection, + KanbanViewSelectionWithType, +} from '../types.js'; + +import { KanbanCard } from '../card.js'; +import { KanbanCell } from '../cell.js'; + +export class KanbanSelectionController implements ReactiveController { + _selection?: KanbanViewSelectionWithType; + + shiftClickCard = (event: MouseEvent) => { + event.preventDefault(); + + const selection = this.selection; + const target = event.target as HTMLElement; + const closestCardId = target.closest( + 'affine-microsheet-data-view-kanban-card' + )?.cardId; + const closestGroupKey = target.closest( + 'affine-microsheet-data-view-kanban-group' + )?.group.key; + if (!closestCardId) return; + if (!closestGroupKey) return; + const cards = selection?.selectionType === 'card' ? selection.cards : []; + + const newCards = cards.some(card => card.cardId === closestCardId) + ? cards.filter(card => card.cardId !== closestCardId) + : [...cards, { cardId: closestCardId, groupKey: closestGroupKey }]; + this.selection = atLeastOne(newCards) + ? { + selectionType: 'card', + cards: newCards, + } + : undefined; + }; + + get selection(): KanbanViewSelectionWithType | undefined { + return this._selection; + } + + set selection(data: KanbanViewSelection | undefined) { + if (!data) { + this.host.props.setSelection(); + return; + } + const selection: KanbanViewSelectionWithType = { + ...data, + viewId: this.host.props.view.id, + type: 'kanban', + }; + + if (selection.selectionType === 'cell' && selection.isEditing) { + const container = getFocusCell(this.host, selection); + const cell = container?.cell; + const isEditing = cell + ? cell.beforeEnterEditMode() + ? selection.isEditing + : false + : false; + this.host.props.setSelection({ + ...selection, + isEditing, + }); + } else { + this.host.props.setSelection(selection); + } + } + + get view() { + return this.host.props.view; + } + + constructor(private host: DataViewKanban) { + this.host.addController(this); + } + + blur(selection: KanbanViewSelection) { + if (selection.selectionType !== 'cell') { + const selectCards = getSelectedCards(this.host, selection); + selectCards.forEach(card => (card.isFocus = false)); + return; + } + const container = getFocusCell(this.host, selection); + if (!container) { + return; + } + container.isFocus = false; + const cell = container?.cell; + + if (selection.isEditing) { + requestAnimationFrame(() => { + cell?.onExitEditMode(); + }); + if (cell?.blurCell()) { + container.blur(); + } + container.editing = false; + } else { + container.blur(); + } + } + + deleteCard() { + const selection = this.selection; + if (!selection || selection.selectionType === 'cell') { + return; + } + if (selection.selectionType === 'card') { + this.host.props.view.rowDelete(selection.cards.map(v => v.cardId)); + this.selection = undefined; + } + } + + focus(selection: KanbanViewSelection) { + if (selection.selectionType !== 'cell') { + const selectCards = getSelectedCards(this.host, selection); + selectCards.forEach((card, index) => { + if (index === 0) { + card.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + } + card.isFocus = true; + }); + return; + } + const container = getFocusCell(this.host, selection); + if (!container) { + return; + } + container.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + container.isFocus = true; + const cell = container?.cell; + if (selection.isEditing) { + cell?.onEnterEditMode(); + if (cell?.focusCell()) { + container.focus(); + } + container.editing = true; + } else { + container.focus(); + } + } + + focusFirstCell() { + const group = this.host.groupManager?.groupsDataList$.value?.[0]; + const card = group?.rows[0]; + const columnId = card && this.host.props.view.getHeaderTitle(card)?.id; + if (group && card && columnId) { + this.selection = { + selectionType: 'cell', + groupKey: group.key, + cardId: card, + columnId, + isEditing: false, + }; + } + } + + focusIn() { + const selection = this.selection; + if (!selection) return; + if (selection.selectionType === 'cell' && selection.isEditing) return; + + if (selection.selectionType === 'cell') { + this.selection = { + ...selection, + isEditing: true, + }; + return; + } + if (selection.selectionType === 'card') { + const card = getSelectedCards(this.host, selection)[0]; + const cell = card?.querySelector( + 'affine-microsheet-data-view-kanban-cell' + ); + if (cell) { + this.selection = { + groupKey: card.groupKey, + cardId: card.cardId, + selectionType: 'cell', + columnId: cell.column.id, + isEditing: false, + }; + } + } else { + // Not yet implement + } + } + + focusNext(position: 'up' | 'down' | 'left' | 'right') { + const selection = this.selection; + if (!selection) { + return; + } + + if (selection.selectionType === 'cell' && !selection.isEditing) { + // cell focus + const kanbanCells = getCardCellsBySelection(this.host, selection); + const index = kanbanCells.findIndex( + cell => cell.column.id === selection.columnId + ); + const { cell, cardId, groupKey } = this.getNextFocusCell( + selection, + index, + position + ); + if (cell instanceof KanbanCell) { + this.selection = { + ...selection, + cardId: cardId ?? selection.cardId, + groupKey: groupKey ?? selection.groupKey, + columnId: cell.column.id, + } satisfies KanbanCellSelection; + } + } else if (selection.selectionType === 'card') { + // card focus + const group = this.host.querySelector( + `affine-microsheet-data-view-kanban-group[data-key="${selection.cards[0].groupKey}"]` + ); + const cardElements = Array.from( + group?.querySelectorAll('affine-microsheet-data-view-kanban-card') ?? [] + ); + + const index = cardElements.findIndex( + card => card.cardId === selection.cards[0].cardId + ); + const { card, cards } = this.getNextFocusCard(selection, index, position); + if (card instanceof KanbanCard) { + const newCards = cards ?? selection.cards; + this.selection = atLeastOne(newCards) + ? { + ...selection, + cards: newCards, + } + : undefined; + } + } + } + + focusOut() { + const selection = this.selection; + if (selection?.selectionType === 'card') { + if (atLeastOne(selection.cards)) { + this.selection = { + ...selection, + cards: [selection.cards[0]], + }; + } else { + // Not yet implement + return; + } + } + if (selection?.selectionType !== 'cell') { + return; + } + + if (selection.isEditing) { + this.selection = { + ...selection, + isEditing: false, + }; + } else { + this.selection = { + selectionType: 'card', + cards: [ + { + cardId: selection.cardId, + groupKey: selection.groupKey, + }, + ], + }; + } + } + + getNextFocusCard( + selection: KanbanCardSelection, + index: number, + nextPosition: 'up' | 'down' | 'left' | 'right' + ): { card: KanbanCard; cards: KanbanCardSelectionCard[] } { + const group = this.host.querySelector( + `affine-microsheet-data-view-kanban-group[data-key="${selection.cards[0].groupKey}"]` + ); + const kanbanCards = Array.from( + group?.querySelectorAll('affine-microsheet-data-view-kanban-card') ?? [] + ); + + if (nextPosition === 'up') { + const nextIndex = index - 1; + const nextCardIndex = nextIndex < 0 ? kanbanCards.length - 1 : nextIndex; + const card = kanbanCards[nextCardIndex]; + + return { + card, + cards: [ + { + cardId: card.cardId, + groupKey: card.groupKey, + }, + ], + }; + } + + if (nextPosition === 'down') { + const nextIndex = index + 1; + const nextCardIndex = nextIndex > kanbanCards.length - 1 ? 0 : nextIndex; + const card = kanbanCards[nextCardIndex]; + + return { + card, + cards: [ + { + cardId: card.cardId, + groupKey: card.groupKey, + }, + ], + }; + } + + const groups = Array.from( + this.host.querySelectorAll('affine-microsheet-data-view-kanban-group') + ); + + if (nextPosition === 'right') { + return getNextGroupFocusElement( + this.host, + groups, + selection, + groupIndex => (groupIndex === groups.length - 1 ? 0 : groupIndex + 1) + ); + } + + if (nextPosition === 'left') { + return getNextGroupFocusElement( + this.host, + groups, + selection, + groupIndex => (groupIndex === 0 ? groups.length - 1 : groupIndex - 1) + ); + } + throw new BlockSuiteError( + ErrorCode.MicrosheetBlockError, + 'Unknown arrow keys, only support: up, down, left, and right keys.' + ); + } + + getNextFocusCell( + selection: KanbanCellSelection, + index: number, + nextPosition: 'up' | 'down' | 'left' | 'right' + ): { + cell: KanbanCell; + cardId?: string; + groupKey?: string; + } { + const kanbanCells = getCardCellsBySelection(this.host, selection); + const group = this.host.querySelector( + `affine-microsheet-data-view-kanban-group[data-key="${selection.groupKey}"]` + ); + const cards = Array.from( + group?.querySelectorAll('affine-microsheet-data-view-kanban-card') ?? [] + ); + + if (nextPosition === 'up') { + const nextIndex = index - 1; + if (nextIndex < 0) { + if (cards.length > 1) { + return getNextCardFocusCell( + nextPosition, + cards, + selection, + cardIndex => (cardIndex === 0 ? cards.length - 1 : cardIndex - 1) + ); + } else { + return { + cell: kanbanCells[kanbanCells.length - 1], + }; + } + } + return { + cell: kanbanCells[nextIndex], + }; + } + + if (nextPosition === 'down') { + const nextIndex = index + 1; + if (nextIndex >= kanbanCells.length) { + if (cards.length > 1) { + return getNextCardFocusCell( + nextPosition, + cards, + selection, + cardIndex => (cardIndex === cards.length - 1 ? 0 : cardIndex + 1) + ); + } else { + return { + cell: kanbanCells[0], + }; + } + } + return { + cell: kanbanCells[nextIndex], + }; + } + + const groups = Array.from( + this.host.querySelectorAll('affine-microsheet-data-view-kanban-group') + ); + + if (nextPosition === 'right') { + return getNextGroupFocusElement( + this.host, + groups, + selection, + groupIndex => (groupIndex === groups.length - 1 ? 0 : groupIndex + 1) + ); + } + + if (nextPosition === 'left') { + return getNextGroupFocusElement( + this.host, + groups, + selection, + groupIndex => (groupIndex === 0 ? groups.length - 1 : groupIndex - 1) + ); + } + throw new BlockSuiteError( + ErrorCode.MicrosheetBlockError, + 'Unknown arrow keys, only support: up, down, left, and right keys.' + ); + } + + hostConnected() { + this.host.disposables.add( + this.host.props.selection$.subscribe(selection => { + const old = this._selection; + if (old) { + this.blur(old); + } + this._selection = selection; + if (selection) { + this.focus(selection); + } + }) + ); + } + + insertRowAfter() { + const selection = this.selection; + if (selection?.selectionType !== 'card') { + return; + } + + const { cardId, groupKey } = selection.cards[0]; + const id = this.view.addCard({ before: false, id: cardId }, groupKey); + + requestAnimationFrame(() => { + const columnId = this.view.mainProperties$.value.titleColumn; + if (columnId) { + this.selection = { + selectionType: 'cell', + groupKey, + cardId: id, + columnId, + isEditing: true, + }; + } else { + this.selection = { + selectionType: 'card', + cards: [ + { + cardId: id, + groupKey, + }, + ], + }; + } + }); + } + + insertRowBefore() { + const selection = this.selection; + if (selection?.selectionType !== 'card') { + return; + } + + const { cardId, groupKey } = selection.cards[0]; + const id = this.view.addCard({ before: true, id: cardId }, groupKey); + + requestAnimationFrame(() => { + const columnId = this.view.mainProperties$.value.titleColumn; + if (columnId) { + this.selection = { + selectionType: 'cell', + groupKey, + cardId: id, + columnId, + isEditing: true, + }; + } else { + this.selection = { + selectionType: 'card', + cards: [ + { + cardId: id, + groupKey, + }, + ], + }; + } + }); + } + + moveCard(rowId: string, key: string) { + const selection = this.selection; + if (selection?.selectionType !== 'card') { + return; + } + this.view.groupManager.moveCardTo( + rowId, + selection.cards[0].groupKey, + key, + 'start' + ); + requestAnimationFrame(() => { + if (this.selection?.selectionType !== 'card') return; + + const newCards = selection.cards.map(card => ({ + ...card, + groupKey: card.groupKey, + })); + this.selection = atLeastOne(newCards) + ? { + ...selection, + cards: newCards, + } + : undefined; + }); + } +} + +type NextFocusCell = { + cell: KanbanCell; + cardId: string; + groupKey: string; +}; +type NextFocusCard = { + card: KanbanCard; + cards: { + cardId: string; + groupKey: string; + }[]; +}; +function getNextGroupFocusElement( + viewElement: Element, + groups: KanbanGroup[], + selection: KanbanCellSelection, + getNextGroupIndex: (groupIndex: number) => number +): NextFocusCell; +function getNextGroupFocusElement( + viewElement: Element, + groups: KanbanGroup[], + selection: KanbanCardSelection, + getNextGroupIndex: (groupIndex: number) => number +): NextFocusCard; +function getNextGroupFocusElement( + viewElement: Element, + groups: KanbanGroup[], + selection: KanbanCellSelection | KanbanCardSelection, + getNextGroupIndex: (groupIndex: number) => number +): NextFocusCell | NextFocusCard { + const groupIndex = groups.findIndex(group => { + if (selection.selectionType === 'cell') { + return group.group.key === selection.groupKey; + } + return group.group.key === selection.cards[0].groupKey; + }); + + let nextGroupIndex = getNextGroupIndex(groupIndex); + let nextGroup = groups[nextGroupIndex]; + while (nextGroup.group.rows.length === 0) { + nextGroupIndex = getNextGroupIndex(nextGroupIndex); + nextGroup = groups[nextGroupIndex]; + } + + const element = + selection.selectionType === 'cell' + ? getFocusCell(viewElement, selection) + : getSelectedCards(viewElement, selection)[0]; + assertExists(element); + const rect = element.getBoundingClientRect(); + const nextCards = Array.from( + nextGroup.querySelectorAll('affine-microsheet-data-view-kanban-card') + ); + const cardPos = nextCards + .map((card, index) => { + const targetRect = card.getBoundingClientRect(); + return { + offsetY: getYOffset(rect, targetRect), + index, + }; + }) + .reduce((prev, curr) => { + if (prev.offsetY < curr.offsetY) { + return prev; + } + return curr; + }); + const nextCard = nextCards[cardPos.index]; + + if (selection.selectionType === 'card') { + return { + card: nextCard, + cards: [ + { + cardId: nextCard.cardId, + groupKey: nextGroup.group.key, + }, + ], + }; + } + + const cells = Array.from( + nextCard.querySelectorAll('affine-microsheet-data-view-kanban-cell') + ); + const cellPos = cells + .map((card, index) => { + const targetRect = card.getBoundingClientRect(); + return { + offsetY: getYOffset(rect, targetRect), + index, + }; + }) + .reduce((prev, curr) => { + if (prev.offsetY < curr.offsetY) { + return prev; + } + return curr; + }); + const nextCell = cells[cellPos.index]; + + return { + cell: nextCell, + cardId: nextCard.cardId, + groupKey: nextGroup.group.key, + }; +} + +function getNextCardFocusCell( + nextPosition: 'up' | 'down', + cards: KanbanCard[], + selection: KanbanCellSelection, + getNextCardIndex: (cardIndex: number) => number +): { + cell: KanbanCell; + cardId: string; +} { + const cardIndex = cards.findIndex(card => card.cardId === selection.cardId); + const nextCardIndex = getNextCardIndex(cardIndex); + const nextCard = cards[nextCardIndex]; + const nextCells = Array.from( + nextCard.querySelectorAll('affine-microsheet-data-view-kanban-cell') + ); + const nextCellIndex = nextPosition === 'up' ? nextCells.length - 1 : 0; + return { + cell: nextCells[nextCellIndex], + cardId: nextCard.cardId, + }; +} + +function getCardCellsBySelection( + viewElement: Element, + selection: KanbanCellSelection +) { + const card = getSelectedCard(viewElement, selection); + return Array.from( + card?.querySelectorAll('affine-microsheet-data-view-kanban-cell') ?? [] + ); +} + +function getSelectedCard( + viewElement: Element, + selection: KanbanCellSelection +): KanbanCard | null { + const group = viewElement.querySelector( + `affine-microsheet-data-view-kanban-group[data-key="${selection.groupKey}"]` + ); + + if (!group) return null; + return group.querySelector( + `affine-microsheet-data-view-kanban-card[data-card-id="${selection.cardId}"]` + ); +} + +function getSelectedCards( + viewElement: Element, + selection: KanbanCardSelection | KanbanGroupSelection +): KanbanCard[] { + if (selection.selectionType === 'group') return []; + + const groupKeys = selection.cards.map(card => card.groupKey); + const groups = groupKeys + .map(key => + viewElement.querySelector( + `affine-microsheet-data-view-kanban-group[data-key="${key}"]` + ) + ) + .filter((group): group is Element => group !== null); + + const cardIds = selection.cards.map(card => card.cardId); + const cards = groups + .flatMap(group => + cardIds.map(id => + group.querySelector( + `affine-microsheet-data-view-kanban-card[data-card-id="${id}"]` + ) + ) + ) + .filter((card): card is KanbanCard => card !== null); + + return cards; +} + +function getFocusCell(viewElement: Element, selection: KanbanCellSelection) { + const card = getSelectedCard(viewElement, selection); + return card?.querySelector( + `affine-microsheet-data-view-kanban-cell[data-column-id="${selection.columnId}"]` + ); +} + +function getYOffset(srcRect: DOMRect, targetRect: DOMRect) { + return Math.abs( + srcRect.top + + (srcRect.bottom - srcRect.top) / 2 - + (targetRect.top + (targetRect.bottom - targetRect.top) / 2) + ); +} +const atLeastOne = (v: T[]): v is [T, ...T[]] => { + return v.length > 0; +}; diff --git a/packages/affine/microsheet-data-view/src/view-presets/kanban/define.ts b/packages/affine/microsheet-data-view/src/view-presets/kanban/define.ts new file mode 100644 index 000000000000..8a5a722df219 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/kanban/define.ts @@ -0,0 +1,92 @@ +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; + +import type { FilterGroup } from '../../core/common/ast.js'; +import type { GroupBy, GroupProperty, Sort } from '../../core/common/types.js'; + +import { + defaultGroupBy, + groupByMatcher, + isTArray, + tRichText, + tString, + tTag, +} from '../../core/index.js'; +import { type BasicViewDataType, viewType } from '../../core/view/data-view.js'; +import { KanbanSingleView } from './kanban-view-manager.js'; + +export const kanbanViewType = viewType('kanban'); + +export type KanbanViewColumn = { + id: string; + hide?: boolean; +}; + +type DataType = { + columns: KanbanViewColumn[]; + filter: FilterGroup; + groupBy?: GroupBy; + sort?: Sort; + header: { + titleColumn?: string; + iconColumn?: string; + coverColumn?: string; + }; + groupProperties: GroupProperty[]; +}; +export type KanbanViewData = BasicViewDataType< + typeof kanbanViewType.type, + DataType +>; +export const kanbanViewModel = kanbanViewType.createModel({ + defaultName: 'Kanban View', + dataViewManager: KanbanSingleView, + defaultData: viewManager => { + const columns = viewManager.dataSource.properties$.value; + const allowList = columns.filter(columnId => { + const dataType = viewManager.dataSource.propertyDataTypeGet(columnId); + return dataType && !!groupByMatcher.match(dataType); + }); + const getWeight = (columnId: string) => { + const dataType = viewManager.dataSource.propertyDataTypeGet(columnId); + if (!dataType || tString.is(dataType) || tRichText.is(dataType)) { + return 0; + } + if (tTag.is(dataType)) { + return 3; + } + if (isTArray(dataType)) { + return 2; + } + return 1; + }; + const columnId = allowList.sort((a, b) => getWeight(b) - getWeight(a))[0]; + const type = viewManager.dataSource.propertyTypeGet(columnId); + const meta = type && viewManager.dataSource.propertyMetaGet(type); + const data = viewManager.dataSource.propertyDataGet(columnId); + if (!columnId || !meta || !data) { + throw new BlockSuiteError( + ErrorCode.MicrosheetBlockError, + 'not implement yet' + ); + } + return { + columns: columns.map(id => ({ + id: id, + hide: false, + })), + filter: { + type: 'group', + op: 'and', + conditions: [], + }, + groupBy: defaultGroupBy(meta, columnId, data), + header: { + titleColumn: viewManager.dataSource.properties$.value.find( + id => viewManager.dataSource.propertyTypeGet(id) === 'title' + ), + iconColumn: 'type', + }, + groupProperties: [], + }; + }, +}); diff --git a/packages/affine/microsheet-data-view/src/view-presets/kanban/group.ts b/packages/affine/microsheet-data-view/src/view-presets/kanban/group.ts new file mode 100644 index 000000000000..00e89306e485 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/kanban/group.ts @@ -0,0 +1,210 @@ +import { + menu, + popFilterableSimpleMenu, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { AddCursorIcon } from '@blocksuite/icons/lit'; +import { css, nothing } from 'lit'; +import { property } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { html } from 'lit/static-html.js'; + +import type { GroupData } from '../../core/common/group-by/helper.js'; +import type { DataViewRenderer } from '../../core/data-view.js'; +import type { KanbanSingleView } from './kanban-view-manager.js'; + +import { GroupTitle } from '../../core/common/group-by/group-title.js'; + +const styles = css` + affine-microsheet-data-view-kanban-group { + width: 260px; + flex-shrink: 0; + border-radius: 8px; + display: flex; + flex-direction: column; + } + + .group-header { + height: 32px; + padding: 6px 4px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + overflow: hidden; + } + + .group-header-title { + overflow: hidden; + display: flex; + align-items: center; + gap: 8px; + font-size: var(--data-view-cell-text-size); + } + + affine-microsheet-data-view-kanban-group:hover .group-header-op { + visibility: visible; + opacity: 1; + } + + .group-body { + margin-top: 4px; + display: flex; + flex-direction: column; + padding: 0 4px; + gap: 12px; + } + + .add-card { + display: flex; + align-items: center; + padding: 4px; + border-radius: 4px; + cursor: pointer; + font-size: var(--data-view-cell-text-size); + line-height: var(--data-view-cell-text-line-height); + visibility: hidden; + opacity: 0; + transition: all 150ms cubic-bezier(0.42, 0, 1, 1); + color: var(--affine-text-secondary-color); + } + + affine-microsheet-data-view-kanban-group:hover .add-card { + visibility: visible; + opacity: 1; + } + + affine-microsheet-data-view-kanban-group .add-card:hover { + background-color: var(--affine-hover-color); + color: var(--affine-text-primary-color); + } + + .sortable-ghost { + background-color: var(--affine-hover-color); + opacity: 0.5; + } + + .sortable-drag { + background-color: var(--affine-background-primary-color); + } +`; + +export class KanbanGroup extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = styles; + + private clickAddCard = () => { + const id = this.view.addCard('end', this.group.key); + requestAnimationFrame(() => { + const kanban = this.closest('affine-microsheet-data-view-kanban'); + if (kanban) { + kanban.selectionController.selection = { + selectionType: 'cell', + groupKey: this.group.key, + cardId: id, + columnId: + this.view.mainProperties$.value.titleColumn || + this.view.propertyIds$.value[0], + isEditing: true, + }; + } + }); + }; + + private clickAddCardInStart = () => { + const id = this.view.addCard('start', this.group.key); + requestAnimationFrame(() => { + const kanban = this.closest('affine-microsheet-data-view-kanban'); + if (kanban) { + kanban.selectionController.selection = { + selectionType: 'cell', + groupKey: this.group.key, + cardId: id, + columnId: + this.view.mainProperties$.value.titleColumn || + this.view.propertyIds$.value[0], + isEditing: true, + }; + } + }); + }; + + private clickGroupOptions = (e: MouseEvent) => { + const ele = e.currentTarget as HTMLElement; + popFilterableSimpleMenu(popupTargetFromElement(ele), [ + menu.action({ + name: 'Ungroup', + hide: () => this.group.value == null, + select: () => { + this.group.rows.forEach(id => { + this.group.manager.removeFromGroup(id, this.group.key); + }); + }, + }), + menu.action({ + name: 'Delete Cards', + select: () => { + this.view.rowDelete(this.group.rows); + }, + }), + ]); + }; + + override render() { + const cards = this.group.rows; + return html` +
+ ${GroupTitle(this.group, { + readonly: this.view.readonly$.value, + clickAdd: this.clickAddCardInStart, + clickOps: this.clickGroupOptions, + })} +
+
+ ${repeat( + cards, + id => id, + id => { + return html` + + `; + } + )} + ${this.view.readonly$.value + ? nothing + : html`
+
+ ${AddCursorIcon()} +
+ Add +
`} +
+ `; + } + + @property({ attribute: false }) + accessor dataViewEle!: DataViewRenderer; + + @property({ attribute: false }) + accessor group!: GroupData; + + @property({ attribute: false }) + accessor view!: KanbanSingleView; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-microsheet-data-view-kanban-group': KanbanGroup; + } +} diff --git a/packages/affine/microsheet-data-view/src/view-presets/kanban/header.ts b/packages/affine/microsheet-data-view/src/view-presets/kanban/header.ts new file mode 100644 index 000000000000..f766a06a6de3 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/kanban/header.ts @@ -0,0 +1,71 @@ +import { + menu, + popMenu, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { css } from 'lit'; +import { property } from 'lit/decorators.js'; +import { html } from 'lit/static-html.js'; + +import type { KanbanSingleView } from './kanban-view-manager.js'; + +const styles = css` + affine-microsheet-data-view-kanban-header { + display: flex; + justify-content: space-between; + padding: 4px; + } + + .select-group { + border-radius: 8px; + padding: 4px 8px; + cursor: pointer; + } + + .select-group:hover { + background-color: var(--affine-hover-color); + } +`; + +export class KanbanHeader extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = styles; + + private clickGroup = (e: MouseEvent) => { + popMenu(popupTargetFromElement(e.target as HTMLElement), { + options: { + items: this.view.properties$.value + .filter(column => column.id !== this.view.view?.groupBy?.columnId) + .map(column => { + return menu.action({ + name: column.name$.value, + select: () => { + this.view.changeGroup(column.id); + }, + }); + }), + }, + }); + }; + + override render() { + return html` +
+
+
Group
+
+ `; + } + + @property({ attribute: false }) + accessor view!: KanbanSingleView; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-microsheet-data-view-kanban-header': KanbanHeader; + } +} diff --git a/packages/affine/microsheet-data-view/src/view-presets/kanban/index.ts b/packages/affine/microsheet-data-view/src/view-presets/kanban/index.ts new file mode 100644 index 000000000000..4000e8247279 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/kanban/index.ts @@ -0,0 +1,4 @@ +export * from './define.js'; +export * from './kanban-view.js'; +export * from './kanban-view-manager.js'; +export * from './renderer.js'; diff --git a/packages/affine/microsheet-data-view/src/view-presets/kanban/kanban-view-manager.ts b/packages/affine/microsheet-data-view/src/view-presets/kanban/kanban-view-manager.ts new file mode 100644 index 000000000000..09f174655aad --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/kanban/kanban-view-manager.ts @@ -0,0 +1,316 @@ +import { + insertPositionToIndex, + type InsertToPosition, +} from '@blocksuite/affine-shared/utils'; +import { computed, type ReadonlySignal } from '@preact/signals-core'; + +import type { TType } from '../../core/logical/typesystem.js'; +import type { KanbanViewData } from './define.js'; + +import { emptyFilterGroup, type FilterGroup } from '../../core/common/ast.js'; +import { defaultGroupBy } from '../../core/common/group-by.js'; +import { + GroupManager, + sortByManually, +} from '../../core/common/group-by/helper.js'; +import { groupByMatcher } from '../../core/common/group-by/matcher.js'; +import { evalFilter } from '../../core/logical/eval-filter.js'; +import { PropertyBase } from '../../core/view-manager/property.js'; +import { SingleViewBase } from '../../core/view-manager/single-view.js'; + +export class KanbanSingleView extends SingleViewBase { + propertiesWithoutFilter$ = computed(() => { + const needShow = new Set(this.dataSource.properties$.value); + const result: string[] = []; + this.data$.value?.columns.forEach(v => { + if (needShow.has(v.id)) { + result.push(v.id); + needShow.delete(v.id); + } + }); + result.push(...needShow); + return result; + }); + + detailProperties$ = computed(() => { + return this.propertiesWithoutFilter$.value.filter( + id => this.propertyTypeGet(id) !== 'title' + ); + }); + + filter$ = computed(() => { + return this.data$.value?.filter ?? emptyFilterGroup; + }); + + groupBy$ = computed(() => { + return this.data$.value?.groupBy; + }); + + groupManager = new GroupManager(this.groupBy$, this, { + sortGroup: ids => + sortByManually( + ids, + v => v, + this.view?.groupProperties.map(v => v.key) ?? [] + ), + sortRow: (key, ids) => { + const property = this.view?.groupProperties.find(v => v.key === key); + return sortByManually(ids, v => v, property?.manuallyCardSort ?? []); + }, + changeGroupSort: keys => { + const map = new Map(this.view?.groupProperties.map(v => [v.key, v])); + this.dataUpdate(() => { + return { + groupProperties: keys.map(key => { + const property = map.get(key); + if (property) { + return property; + } + return { + key, + hide: false, + manuallyCardSort: [], + }; + }), + }; + }); + }, + changeRowSort: (groupKeys, groupKey, keys) => { + const map = new Map(this.view?.groupProperties.map(v => [v.key, v])); + this.dataUpdate(() => { + return { + groupProperties: groupKeys.map(key => { + if (key === groupKey) { + const group = map.get(key); + return group + ? { + ...group, + manuallyCardSort: keys, + } + : { + key, + hide: false, + manuallyCardSort: keys, + }; + } else { + return ( + map.get(key) ?? { + key, + hide: false, + manuallyCardSort: [], + } + ); + } + }), + }; + }); + }, + }); + + mainProperties$ = computed(() => { + return ( + this.data$.value?.header ?? { + titleColumn: this.propertiesWithoutFilter$.value.find( + id => this.propertyTypeGet(id) === 'title' + ), + iconColumn: 'type', + } + ); + }); + + propertyIds$: ReadonlySignal = computed(() => { + return this.propertiesWithoutFilter$.value.filter( + id => !this.propertyHideGet(id) + ); + }); + + readonly$ = computed(() => { + return this.manager.readonly$.value; + }); + + get columns(): string[] { + return this.propertiesWithoutFilter$.value.filter( + id => !this.propertyHideGet(id) + ); + } + + get columnsWithoutFilter(): string[] { + const needShow = new Set(this.dataSource.properties$.value); + const result: string[] = []; + this.view?.columns.forEach(v => { + if (needShow.has(v.id)) { + result.push(v.id); + needShow.delete(v.id); + } + }); + result.push(...needShow); + return result; + } + + get filter(): FilterGroup { + return this.view?.filter ?? emptyFilterGroup; + } + + get header() { + return this.view?.header; + } + + get type(): string { + return this.view?.mode ?? 'kanban'; + } + + get view() { + return this.data$.value; + } + + addCard(position: InsertToPosition, group: string) { + const id = this.rowAdd(position); + this.groupManager.addToGroup(id, group); + return id; + } + + changeGroup(columnId: string) { + const column = this.propertyGet(columnId); + this.dataUpdate(_view => { + return { + groupBy: defaultGroupBy( + this.propertyMetaGet(column.type$.value), + column.id, + column.data$.value + ), + }; + }); + } + + checkGroup(columnId: string, type: TType, target: TType): boolean { + if (!groupByMatcher.isMatched(type, target)) { + this.changeGroup(columnId); + return false; + } + return true; + } + + filterSet(filter: FilterGroup): void { + this.dataUpdate(() => { + return { + filter, + }; + }); + } + + getHeaderCover(_rowId: string): KanbanColumn | undefined { + const columnId = this.view?.header.coverColumn; + if (!columnId) { + return; + } + return this.propertyGet(columnId); + } + + getHeaderIcon(_rowId: string): KanbanColumn | undefined { + const columnId = this.view?.header.iconColumn; + if (!columnId) { + return; + } + return this.propertyGet(columnId); + } + + getHeaderTitle(_rowId: string): KanbanColumn | undefined { + const columnId = this.view?.header.titleColumn; + if (!columnId) { + return; + } + return this.propertyGet(columnId); + } + + hasHeader(_rowId: string): boolean { + const hd = this.view?.header; + if (!hd) { + return false; + } + return !!hd.titleColumn || !!hd.iconColumn || !!hd.coverColumn; + } + + isInHeader(columnId: string) { + const hd = this.view?.header; + if (!hd) { + return false; + } + return ( + hd.titleColumn === columnId || + hd.iconColumn === columnId || + hd.coverColumn === columnId + ); + } + + isShow(rowId: string): boolean { + if (this.filter$.value?.conditions.length) { + const rowMap = Object.fromEntries( + this.properties$.value.map(column => [ + column.id, + column.cellGet(rowId).jsonValue$.value, + ]) + ); + return evalFilter(this.filter$.value, rowMap); + } + return true; + } + + propertyGet(columnId: string): KanbanColumn { + return new KanbanColumn(this, columnId); + } + + propertyHideGet(columnId: string): boolean { + return this.view?.columns.find(v => v.id === columnId)?.hide ?? false; + } + + propertyHideSet(columnId: string, hide: boolean): void { + this.dataUpdate(view => { + return { + columns: view.columns.map(v => + v.id === columnId + ? { + ...v, + hide, + } + : v + ), + }; + }); + } + + propertyMove(columnId: string, toAfterOfColumn: InsertToPosition): void { + this.dataUpdate(view => { + const columnIndex = view.columns.findIndex(v => v.id === columnId); + if (columnIndex < 0) { + return {}; + } + const columns = [...view.columns]; + const [column] = columns.splice(columnIndex, 1); + const index = insertPositionToIndex(toAfterOfColumn, columns); + columns.splice(index, 0, column); + return { + columns, + }; + }); + } + + override rowMove(rowId: string, position: InsertToPosition): void { + this.dataSource.rowMove(rowId, position); + } + + override rowNextGet(rowId: string): string { + const index = this.rows$.value.indexOf(rowId); + return this.rows$.value[index + 1]; + } + + override rowPrevGet(rowId: string): string { + const index = this.rows$.value.indexOf(rowId); + return this.rows$.value[index - 1]; + } +} + +export class KanbanColumn extends PropertyBase { + constructor(dataViewManager: KanbanSingleView, columnId: string) { + super(dataViewManager, columnId); + } +} diff --git a/packages/affine/microsheet-data-view/src/view-presets/kanban/kanban-view.ts b/packages/affine/microsheet-data-view/src/view-presets/kanban/kanban-view.ts new file mode 100644 index 000000000000..fcd6a95a99b2 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/kanban/kanban-view.ts @@ -0,0 +1,266 @@ +import { + menu, + popMenu, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { AddCursorIcon } from '@blocksuite/icons/lit'; +import { css } from 'lit'; +import { query } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { html } from 'lit/static-html.js'; +import Sortable from 'sortablejs'; + +import type { KanbanSingleView } from './kanban-view-manager.js'; +import type { KanbanViewSelectionWithType } from './types.js'; + +import { type DataViewExpose, renderUniLit } from '../../core/index.js'; +import { DataViewBase } from '../../core/view/data-view-base.js'; +import { KanbanClipboardController } from './controller/clipboard.js'; +import { KanbanDragController } from './controller/drag.js'; +import { KanbanHotkeysController } from './controller/hotkeys.js'; +import { KanbanSelectionController } from './controller/selection.js'; +import { KanbanGroup } from './group.js'; + +const styles = css` + affine-microsheet-data-view-kanban { + user-select: none; + display: flex; + flex-direction: column; + } + + .affine-microsheet-data-view-kanban-groups { + position: relative; + z-index: 1; + display: flex; + gap: 20px; + padding-bottom: 4px; + overflow-x: scroll; + overflow-y: hidden; + } + + .affine-microsheet-data-view-kanban-groups:hover { + padding-bottom: 0px; + } + + .affine-microsheet-data-view-kanban-groups::-webkit-scrollbar { + -webkit-appearance: none; + display: block; + } + + .affine-microsheet-data-view-kanban-groups::-webkit-scrollbar:horizontal { + height: 4px; + } + + .affine-microsheet-data-view-kanban-groups::-webkit-scrollbar-thumb { + border-radius: 2px; + background-color: transparent; + } + + .affine-microsheet-data-view-kanban-groups:hover::-webkit-scrollbar:horizontal { + height: 8px; + } + + .affine-microsheet-data-view-kanban-groups:hover::-webkit-scrollbar-thumb { + border-radius: 16px; + background-color: var(--affine-black-30); + } + + .affine-microsheet-data-view-kanban-groups:hover::-webkit-scrollbar-track { + background-color: var(--affine-hover-color); + } + + .add-group-icon { + padding: 4px; + border-radius: 4px; + display: flex; + align-items: center; + cursor: pointer; + } + + .add-group-icon:hover { + background-color: var(--affine-hover-color); + } + + .add-group-icon svg { + width: 16px; + height: 16px; + fill: var(--affine-icon-color); + color: var(--affine-icon-color); + } +`; + +export class DataViewKanban extends DataViewBase< + KanbanSingleView, + KanbanViewSelectionWithType +> { + static override styles = styles; + + private dragController = new KanbanDragController(this); + + clipboardController = new KanbanClipboardController(this); + + selectionController = new KanbanSelectionController(this); + + expose: DataViewExpose = { + focusFirstCell: () => { + this.selectionController.focusFirstCell(); + }, + getSelection: () => { + return this.selectionController.selection; + }, + hideIndicator: () => { + this.dragController.dropPreview.remove(); + }, + moveTo: (id, evt) => { + const position = this.dragController.getInsertPosition(evt); + if (position) { + position.group.group.manager.moveCardTo( + id, + '', + position.group.group.key, + position.position + ); + } + }, + showIndicator: evt => { + return this.dragController.shooIndicator(evt, undefined) != null; + }, + }; + + hotkeysController = new KanbanHotkeysController(this); + + onWheel = (event: WheelEvent) => { + if (event.metaKey || event.ctrlKey) { + return; + } + const ele = event.currentTarget; + if (ele instanceof HTMLElement) { + if (ele.scrollWidth === ele.clientWidth) { + return; + } + event.stopPropagation(); + } + }; + + renderAddGroup = () => { + const addGroup = this.groupManager.addGroup; + if (!addGroup) { + return; + } + const add = (e: MouseEvent) => { + const ele = e.currentTarget as HTMLElement; + popMenu(popupTargetFromElement(ele), { + options: { + items: [ + menu.input({ + onComplete: text => { + const column = this.groupManager.property$.value; + if (column) { + column.dataUpdate( + () => addGroup(text, column.data$.value) as never + ); + } + }, + }), + ], + }, + }); + }; + return html`
+
${AddCursorIcon()}
+
`; + }; + + get groupManager() { + return this.props.view.groupManager; + } + + override firstUpdated() { + const sortable = Sortable.create(this.groups, { + group: `kanban-group-drag-${this.props.view.id}`, + handle: '.group-header', + draggable: 'affine-microsheet-data-view-kanban-group', + animation: 100, + onEnd: evt => { + if (evt.item instanceof KanbanGroup) { + const groups = Array.from( + this.groups.querySelectorAll( + 'affine-microsheet-data-view-kanban-group' + ) + ); + + const key = + evt.newIndex != null + ? groups[evt.newIndex - 1]?.group.key + : undefined; + this.groupManager?.moveGroupTo( + evt.item.group.key, + key + ? { + before: false, + id: key, + } + : 'start' + ); + } + }, + }); + this._disposables.add({ + dispose: () => { + sortable.destroy(); + }, + }); + } + + override render() { + const groups = this.groupManager.groupsDataList$.value; + if (!groups) { + return html``; + } + const vPadding = this.props.virtualPadding$.value; + const wrapperStyle = styleMap({ + marginLeft: `-${vPadding}px`, + marginRight: `-${vPadding}px`, + paddingLeft: `${vPadding}px`, + paddingRight: `${vPadding}px`, + }); + return html` + ${renderUniLit(this.props.headerWidget, { + view: this.props.view, + viewMethods: this.expose, + })} +
+ ${repeat( + groups, + group => group.key, + group => { + return html` `; + } + )} + ${this.renderAddGroup()} +
+ `; + } + + @query('.affine-microsheet-data-view-kanban-groups') + accessor groups!: HTMLElement; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-microsheet-data-view-kanban': DataViewKanban; + } +} diff --git a/packages/affine/microsheet-data-view/src/view-presets/kanban/menu.ts b/packages/affine/microsheet-data-view/src/view-presets/kanban/menu.ts new file mode 100644 index 000000000000..a7e621dd54dc --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/kanban/menu.ts @@ -0,0 +1,112 @@ +import { + menu, + popFilterableSimpleMenu, + type PopupTarget, +} from '@blocksuite/affine-components/context-menu'; +import { + ArrowRightBigIcon, + DeleteIcon, + ExpandFullIcon, + MoveLeftIcon, + MoveRightIcon, +} from '@blocksuite/icons/lit'; +import { html } from 'lit'; + +import type { DataViewRenderer } from '../../core/data-view.js'; +import type { KanbanSelectionController } from './controller/selection.js'; + +export const openDetail = ( + dataViewEle: DataViewRenderer, + rowId: string, + selection: KanbanSelectionController +) => { + const old = selection.selection; + selection.selection = undefined; + dataViewEle.openDetailPanel({ + view: selection.view, + rowId: rowId, + onClose: () => { + selection.selection = old; + }, + }); +}; + +export const popCardMenu = ( + dataViewEle: DataViewRenderer, + ele: PopupTarget, + rowId: string, + selection: KanbanSelectionController +) => { + popFilterableSimpleMenu(ele, [ + menu.action({ + name: 'Expand Card', + prefix: ExpandFullIcon(), + select: () => { + openDetail(dataViewEle, rowId, selection); + }, + }), + menu.subMenu({ + name: 'Move To', + prefix: ArrowRightBigIcon(), + options: { + items: + selection.view.groupManager.groupsDataList$.value + ?.filter(v => { + const cardSelection = selection.selection; + if (cardSelection?.selectionType === 'card') { + return v.key !== cardSelection?.cards[0].groupKey; + } + return false; + }) + .map(group => { + return menu.action({ + name: group.value != null ? group.name : 'Ungroup', + select: () => { + selection.moveCard(rowId, group.key); + }, + }); + }) ?? [], + }, + }), + menu.group({ + name: '', + items: [ + menu.action({ + name: 'Insert Before', + prefix: html`
+ ${MoveLeftIcon()} +
`, + select: () => { + selection.insertRowBefore(); + }, + }), + menu.action({ + name: 'Insert After', + prefix: html`
+ ${MoveRightIcon()} +
`, + select: () => { + selection.insertRowAfter(); + }, + }), + ], + }), + menu.group({ + name: '', + items: [ + menu.action({ + name: 'Delete Card', + class: 'delete-item', + prefix: DeleteIcon(), + select: () => { + selection.deleteCard(); + }, + }), + ], + }), + ]); +}; diff --git a/packages/affine/microsheet-data-view/src/view-presets/kanban/renderer.ts b/packages/affine/microsheet-data-view/src/view-presets/kanban/renderer.ts new file mode 100644 index 000000000000..47ae144de002 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/kanban/renderer.ts @@ -0,0 +1,9 @@ +import { createUniComponentFromWebComponent } from '../../core/index.js'; +import { createIcon } from '../../core/utils/uni-icon.js'; +import { kanbanViewModel } from './define.js'; +import { DataViewKanban } from './kanban-view.js'; + +export const kanbanViewMeta = kanbanViewModel.createMeta({ + icon: createIcon('MicrosheetKanbanViewIcon'), + view: createUniComponentFromWebComponent(DataViewKanban), +}); diff --git a/packages/affine/microsheet-data-view/src/view-presets/kanban/types.ts b/packages/affine/microsheet-data-view/src/view-presets/kanban/types.ts new file mode 100644 index 000000000000..f79dbdcf5139 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/kanban/types.ts @@ -0,0 +1,32 @@ +type WithKanbanViewType = T extends unknown + ? { + viewId: string; + type: 'kanban'; + } & T + : never; + +export type KanbanCellSelection = { + selectionType: 'cell'; + groupKey: string; + cardId: string; + columnId: string; + isEditing: boolean; +}; +export type KanbanCardSelectionCard = { + groupKey: string; + cardId: string; +}; +export type KanbanCardSelection = { + selectionType: 'card'; + cards: [KanbanCardSelectionCard, ...KanbanCardSelectionCard[]]; +}; +export type KanbanGroupSelection = { + selectionType: 'group'; + groupKeys: [string, ...string[]]; +}; +export type KanbanViewSelection = + | KanbanCellSelection + | KanbanCardSelection + | KanbanGroupSelection; +export type KanbanViewSelectionWithType = + WithKanbanViewType; diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/cell.ts b/packages/affine/microsheet-data-view/src/view-presets/table/cell.ts new file mode 100644 index 000000000000..886083a1936a --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/table/cell.ts @@ -0,0 +1,185 @@ +import { type BlockStdScope, ShadowlessElement } from '@blocksuite/block-std'; +import { + assertExists, + SignalWatcher, + WithDisposable, +} from '@blocksuite/global/utils'; +import { computed } from '@preact/signals-core'; +import { css, html, nothing } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { createRef } from 'lit/directives/ref.js'; + +import type { + CellRenderProps, + DataViewCellLifeCycle, +} from '../../core/property/index.js'; +import type { SingleView } from '../../core/view-manager/single-view.js'; +import type { TableColumn } from './table-view-manager.js'; + +import { renderUniLit } from '../../core/index.js'; +import { + TableAreaSelection, + type TableViewSelectionWithType, +} from './types.js'; + +export class MicrosheetCellContainer extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = css` + affine-microsheet-cell-container { + display: flex; + align-items: start; + width: 100%; + height: 100%; + border: none; + outline: none; + } + + affine-microsheet-cell-container * { + box-sizing: border-box; + } + + affine-microsheet-cell-container microsheet-uni-lit > *:first-child { + padding: 8px; + } + `; + + private _cell = createRef(); + + @property({ attribute: false }) + accessor column!: TableColumn; + + @property({ attribute: false }) + accessor rowId!: string; + + cell$ = computed(() => { + return this.column.cellGet(this.rowId); + }); + + selectCurrentCell = (editing: boolean) => { + if (this.view.readonly$.value) { + return; + } + const selectionView = this.selectionView; + if (selectionView) { + const selection = selectionView.selection; + if (selection && this.isSelected(selection) && editing) { + selectionView.selection = TableAreaSelection.create({ + groupKey: this.groupKey, + focus: { + rowIndex: this.rowIndex, + columnIndex: this.columnIndex, + }, + isEditing: true, + }); + } else { + selectionView.selection = TableAreaSelection.create({ + groupKey: this.groupKey, + focus: { + rowIndex: this.rowIndex, + columnIndex: this.columnIndex, + }, + isEditing: false, + }); + } + } + }; + + get cell(): DataViewCellLifeCycle | undefined { + return this._cell.value; + } + + private get groupKey() { + return this.closest('affine-microsheet-data-view-table-group')?.group?.key; + } + + private get readonly() { + return this.column.readonly$.value; + } + + private get selectionView() { + return this.closest('affine-microsheet-table')?.selectionController; + } + + get table() { + const table = this.closest('affine-microsheet-table'); + assertExists(table); + return table; + } + + override connectedCallback() { + super.connectedCallback(); + this._disposables.addFromEvent(this, 'click', () => { + if (!this.isEditing) { + this.selectCurrentCell(!this.column.readonly$.value); + } + }); + } + + isSelected(selection: TableViewSelectionWithType) { + if (selection.selectionType !== 'area') { + return false; + } + if (selection.groupKey !== this.groupKey) { + return; + } + if (selection.focus.columnIndex !== this.columnIndex) { + return; + } + return selection.focus.rowIndex === this.rowIndex; + } + + override render() { + if (!this.std) return nothing; + + const refId = this.view.cellRefGet(this.rowId, this.column.id); + if (!refId) return; + + const refModel = this.std.doc.getBlockById(refId as string); + + assertExists(refModel); + return html``; + const renderer = this.column.renderer$.value; + if (!renderer) { + return; + } + const { edit, view } = renderer; + const uni = !this.readonly && this.isEditing && edit != null ? edit : view; + const props: CellRenderProps = { + cell: this.cell$.value, + isEditing: this.isEditing, + selectCurrentCell: this.selectCurrentCell, + }; + + return renderUniLit(uni, props, { + ref: this._cell, + style: { + display: 'contents', + }, + }); + } + + @property({ attribute: false }) + accessor columnId!: string; + + @property({ attribute: false }) + accessor columnIndex!: number; + + @state() + accessor isEditing = false; + + @property({ attribute: false }) + accessor rowIndex!: number; + + @property({ attribute: false }) + accessor std!: BlockStdScope; + + @property({ attribute: false }) + accessor view!: SingleView; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-microsheet-cell-container': MicrosheetCellContainer; + } +} diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/components/menu.ts b/packages/affine/microsheet-data-view/src/view-presets/table/components/menu.ts new file mode 100644 index 000000000000..768d1b8795e9 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/table/components/menu.ts @@ -0,0 +1,130 @@ +import { + menu, + popFilterableSimpleMenu, + type PopupTarget, +} from '@blocksuite/affine-components/context-menu'; +import { + CopyIcon, + DeleteIcon, + ExpandFullIcon, + MoveLeftIcon, + MoveRightIcon, +} from '@blocksuite/icons/lit'; +import { html } from 'lit'; + +import type { DataViewRenderer } from '../../../core/data-view.js'; +import type { TableSelectionController } from '../controller/selection.js'; + +import { TableRowSelection } from '../types.js'; + +export const openDetail = ( + dataViewEle: DataViewRenderer, + rowId: string, + selection: TableSelectionController +) => { + const old = selection.selection; + selection.selection = undefined; + dataViewEle.openDetailPanel({ + view: selection.host.props.view, + rowId: rowId, + onClose: () => { + selection.selection = old; + }, + }); +}; + +export const popRowMenu = ( + dataViewEle: DataViewRenderer, + ele: PopupTarget, + selectionController: TableSelectionController +) => { + const selection = selectionController.selection; + if (!TableRowSelection.is(selection)) { + return; + } + if (selection.rows.length > 1) { + const rows = TableRowSelection.rowsIds(selection); + popFilterableSimpleMenu(ele, [ + menu.group({ + name: '', + items: [ + menu.action({ + name: 'Copy', + prefix: html`
+ ${CopyIcon()} +
`, + select: () => { + selectionController.host.clipboardController.copy(); + }, + }), + ], + }), + menu.group({ + name: '', + items: [ + menu.action({ + name: 'Delete Rows', + class: 'delete-item', + prefix: DeleteIcon(), + select: () => { + selectionController.view.rowDelete(rows); + }, + }), + ], + }), + ]); + return; + } + const row = selection.rows[0]; + popFilterableSimpleMenu(ele, [ + menu.action({ + name: 'Expand Row', + prefix: ExpandFullIcon(), + select: () => { + openDetail(dataViewEle, row.id, selectionController); + }, + }), + menu.group({ + name: '', + items: [ + menu.action({ + name: 'Insert Before', + prefix: html`
+ ${MoveLeftIcon()} +
`, + select: () => { + selectionController.insertRowBefore(row.groupKey, row.id); + }, + }), + menu.action({ + name: 'Insert After', + prefix: html`
+ ${MoveRightIcon()} +
`, + select: () => { + selectionController.insertRowAfter(row.groupKey, row.id); + }, + }), + ], + }), + menu.group({ + name: '', + items: [ + menu.action({ + name: 'Delete Row', + class: 'delete-item', + prefix: DeleteIcon(), + select: () => { + selectionController.deleteRow(row.id); + }, + }), + ], + }), + ]); +}; diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/consts.ts b/packages/affine/microsheet-data-view/src/view-presets/table/consts.ts new file mode 100644 index 000000000000..1a60741202af --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/table/consts.ts @@ -0,0 +1,10 @@ +/** column default width */ +export const DEFAULT_COLUMN_WIDTH = 180; +/** column min width */ +export const DEFAULT_COLUMN_MIN_WIDTH = 100; +/** column title height */ +export const DEFAULT_COLUMN_TITLE_HEIGHT = 36; +/** column title height */ +export const DEFAULT_ADD_BUTTON_WIDTH = 40; +export const LEFT_TOOL_BAR_WIDTH = 24; +export const STATS_BAR_HEIGHT = 34; diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/controller/clipboard.ts b/packages/affine/microsheet-data-view/src/view-presets/table/controller/clipboard.ts new file mode 100644 index 000000000000..b442953268af --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/table/controller/clipboard.ts @@ -0,0 +1,286 @@ +import type { UIEventStateContext } from '@blocksuite/block-std'; +import type { ReactiveController } from 'lit'; + +import { toast } from '@blocksuite/affine-components/toast'; + +import type { Cell } from '../../../core/view-manager/cell.js'; +import type { Row } from '../../../core/view-manager/row.js'; +import type { DataViewTable } from '../table-view.js'; + +import { + TableAreaSelection, + TableRowSelection, + type TableViewSelection, + type TableViewSelectionWithType, +} from '../types.js'; + +const BLOCKSUITE_MICROSHEET_TABLE = 'blocksuite/microsheet/table'; +type JsonAreaData = string[][]; +const TEXT = 'text/plain'; + +export class TableClipboardController implements ReactiveController { + private _onCopy = ( + tableSelection: TableViewSelectionWithType, + isCut = false + ) => { + const table = this.host; + + const area = getSelectedArea(tableSelection, table); + if (!area) { + return; + } + const stringResult = area + .map(row => row.cells.map(cell => cell.stringValue$.value).join('\t')) + .join('\n'); + const jsonResult: JsonAreaData = area.map(row => + row.cells.map(cell => cell.stringValue$.value) + ); + if (isCut) { + const deleteRows: string[] = []; + for (const row of area) { + if (row.row) { + deleteRows.push(row.row.rowId); + } else { + for (const cell of row.cells) { + cell.valueSet(undefined); + } + } + } + if (deleteRows.length) { + this.props.view.rowDelete(deleteRows); + } + } + this.std.clipboard + .writeToClipboard(items => { + return { + ...items, + [TEXT]: stringResult, + [BLOCKSUITE_MICROSHEET_TABLE]: JSON.stringify(jsonResult), + }; + }) + .then(() => { + if (area[0]?.row) { + toast( + this.std.host, + `${area.length} row${area.length > 1 ? 's' : ''} copied to clipboard` + ); + } else { + const count = area.flatMap(row => row.cells).length; + toast( + this.std.host, + `${count} cell${count > 1 ? 's' : ''} copied to clipboard` + ); + } + }) + .catch(console.error); + + return true; + }; + + private _onCut = (tableSelection: TableViewSelectionWithType) => { + this._onCopy(tableSelection, true); + }; + + private _onPaste = async (_context: UIEventStateContext) => { + const event = _context.get('clipboardState').raw; + event.stopPropagation(); + const view = this.host; + + const clipboardData = event.clipboardData; + if (!clipboardData) return; + + const tableSelection = this.host.selectionController.selection; + if (TableRowSelection.is(tableSelection)) { + return; + } + if (tableSelection) { + const json = await this.std.clipboard.readFromClipboard(clipboardData); + const dataString = json[BLOCKSUITE_MICROSHEET_TABLE]; + if (!dataString) return; + const jsonAreaData = JSON.parse(dataString) as JsonAreaData; + pasteToCells(view, jsonAreaData, tableSelection); + } + + return true; + }; + + get props() { + return this.host.props; + } + + private get readonly() { + return this.props.view.readonly$.value; + } + + private get std() { + return this.props.std; + } + + constructor(public host: DataViewTable) { + host.addController(this); + } + + copy() { + const tableSelection = this.host.selectionController.selection; + if (!tableSelection) { + return; + } + this._onCopy(tableSelection); + } + + cut() { + const tableSelection = this.host.selectionController.selection; + if (!tableSelection) { + return; + } + this._onCopy(tableSelection, true); + } + + hostConnected() { + this.host.disposables.add( + this.props.handleEvent('copy', _ctx => { + const tableSelection = this.host.selectionController.selection; + if (!tableSelection) return false; + + this._onCopy(tableSelection); + return true; + }) + ); + + this.host.disposables.add( + this.props.handleEvent('cut', _ctx => { + const tableSelection = this.host.selectionController.selection; + if (!tableSelection) return false; + + this._onCut(tableSelection); + return true; + }) + ); + + this.host.disposables.add( + this.props.handleEvent('paste', ctx => { + if (this.readonly) return false; + + this._onPaste(ctx).catch(console.error); + return true; + }) + ); + } +} + +function getSelectedArea( + selection: TableViewSelection, + table: DataViewTable +): SelectedArea | undefined { + const view = table.props.view; + if (TableRowSelection.is(selection)) { + const rows = TableRowSelection.rows(selection) + .map(row => { + const y = + table.selectionController + .getRow(row.groupKey, row.id) + ?.getBoundingClientRect().y ?? 0; + return { + y, + row, + }; + }) + .sort((a, b) => a.y - b.y) + .map(v => v.row); + return rows.map(r => { + const row = view.rowGet(r.id); + return { + row, + cells: row.cells$.value, + }; + }); + } + const { rowsSelection, columnsSelection, groupKey } = selection; + const data: SelectedArea = []; + const rows = groupKey + ? view.groupManager.groupDataMap$.value?.[groupKey].rows + : view.rows$.value; + const columns = view.propertyIds$.value; + if (!rows) { + return; + } + for (let i = rowsSelection.start; i <= rowsSelection.end; i++) { + const row: SelectedArea[number] = { + cells: [], + }; + const rowId = rows[i]; + for (let j = columnsSelection.start; j <= columnsSelection.end; j++) { + const columnId = columns[j]; + const cell = view.cellGet(rowId, columnId); + row.cells.push(cell); + } + data.push(row); + } + + return data; +} + +type SelectedArea = { + row?: Row; + cells: Cell[]; +}[]; + +function getTargetRangeFromSelection( + selection: TableAreaSelection, + data: JsonAreaData +) { + const { rowsSelection, columnsSelection, focus } = selection; + return TableAreaSelection.isFocus(selection) + ? { + row: { + start: focus.rowIndex, + length: data.length, + }, + column: { + start: focus.columnIndex, + length: data[0].length, + }, + } + : { + row: { + start: rowsSelection.start, + length: rowsSelection.end - rowsSelection.start + 1, + }, + column: { + start: columnsSelection.start, + length: columnsSelection.end - columnsSelection.start + 1, + }, + }; +} + +function pasteToCells( + table: DataViewTable, + rows: JsonAreaData, + selection: TableAreaSelection +) { + const srcRowLength = rows.length; + const srcColumnLength = rows[0].length; + const targetRange = getTargetRangeFromSelection(selection, rows); + for (let i = 0; i < targetRange.row.length; i++) { + for (let j = 0; j < targetRange.column.length; j++) { + const rowIndex = targetRange.row.start + i; + const columnIndex = targetRange.column.start + j; + + const srcRowIndex = i % srcRowLength; + const srcColumnIndex = j % srcColumnLength; + const dataString = rows[srcRowIndex][srcColumnIndex]; + + const targetContainer = table.selectionController.getCellContainer( + selection.groupKey, + rowIndex, + columnIndex + ); + const rowId = targetContainer?.dataset.rowId; + const columnId = targetContainer?.dataset.columnId; + + if (rowId && columnId) { + targetContainer?.column.valueSetFromString(rowId, dataString); + } + } + } +} diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/controller/drag-to-fill.ts b/packages/affine/microsheet-data-view/src/view-presets/table/controller/drag-to-fill.ts new file mode 100644 index 000000000000..f2a1c00ffceb --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/table/controller/drag-to-fill.ts @@ -0,0 +1,111 @@ +import { ShadowlessElement } from '@blocksuite/block-std'; +import { assertEquals } from '@blocksuite/global/utils'; +import { DocCollection, type Text } from '@blocksuite/store'; +import { css, html } from 'lit'; +import { state } from 'lit/decorators.js'; +import { createRef, ref } from 'lit/directives/ref.js'; + +import type { DataViewTable } from '../table-view.js'; +import type { TableAreaSelection } from '../types.js'; + +import { tRichText } from '../../../core/logical/data-type.js'; + +export class DragToFillElement extends ShadowlessElement { + static override styles = css` + .drag-to-fill { + border-radius: 50%; + box-sizing: border-box; + background-color: var(--affine-background-primary-color); + border: 2px solid var(--affine-primary-color); + display: none; + position: absolute; + cursor: ns-resize; + width: 10px; + height: 10px; + transform: translate(-50%, -50%); + pointer-events: auto; + user-select: none; + transition: scale 0.2s ease; + z-index: 2; + } + .drag-to-fill.dragging { + scale: 1.1; + } + `; + + dragToFillRef = createRef(); + + override render() { + // TODO add tooltip + return html`
`; + } + + @state() + accessor dragging = false; +} + +export function fillSelectionWithFocusCellData( + host: DataViewTable, + selection: TableAreaSelection +) { + const { groupKey, rowsSelection, columnsSelection, focus } = selection; + + const focusCell = host.selectionController.getCellContainer( + groupKey, + focus.rowIndex, + focus.columnIndex + ); + + if (!focusCell) return; + + if (rowsSelection && columnsSelection) { + assertEquals( + columnsSelection.start, + columnsSelection.end, + 'expected selections on a single column' + ); + + const curCol = focusCell.column; // we are sure that we are always in the same column while iterating through rows + const cell = focusCell.cell$.value; + const focusData = cell.value$.value; + + const draggingColIdx = columnsSelection.start; + const { start, end } = rowsSelection; + + for (let i = start; i <= end; i++) { + if (i === focus.rowIndex) continue; + + const cellContainer = host.selectionController.getCellContainer( + groupKey, + i, + draggingColIdx + ); + + if (!cellContainer) continue; + + const curCell = cellContainer.cell$.value; + + if (tRichText.is(curCol.dataType$.value)) { + const focusCellText = focusData as Text | undefined; + + const delta = focusCellText?.toDelta() ?? [{ insert: '' }]; + const curCellText = curCell.value$.value as Text | undefined; + + if (curCellText) { + curCellText.clear(); + curCellText.applyDelta(delta); + } else { + const newText = new DocCollection.Y.Text(); + newText.applyDelta(delta); + curCell.valueSet(newText); + } + } else { + curCell.valueSet(focusData); + } + } + } +} diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/controller/drag.ts b/packages/affine/microsheet-data-view/src/view-presets/table/controller/drag.ts new file mode 100644 index 000000000000..4571749f1cf8 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/table/controller/drag.ts @@ -0,0 +1,210 @@ +// related component + +import type { InsertToPosition } from '@blocksuite/affine-shared/utils'; +import type { ReactiveController } from 'lit'; + +import type { DataViewTable } from '../table-view.js'; + +import { startDrag } from '../../../core/utils/drag.js'; +import { TableRow } from '../row/row.js'; + +export class TableDragController implements ReactiveController { + dragStart = (row: TableRow, evt: PointerEvent) => { + const eleRect = row.getBoundingClientRect(); + const offsetLeft = evt.x - eleRect.left; + const offsetTop = evt.y - eleRect.top; + const preview = createDragPreview( + row, + evt.x - offsetLeft, + evt.y - offsetTop + ); + const fromGroup = row.groupKey; + + startDrag< + | undefined + | { + type: 'self'; + groupKey?: string; + position: InsertToPosition; + } + | { type: 'out'; callback: () => void }, + PointerEvent + >(evt, { + onDrag: () => undefined, + onMove: evt => { + preview.display(evt.x - offsetLeft, evt.y - offsetTop); + if (!this.host.contains(evt.target as Node)) { + const callback = this.host.props.onDrag; + if (callback) { + this.dropPreview.remove(); + return { + type: 'out', + callback: callback(evt, row.rowId), + }; + } + return; + } + const result = this.showIndicator(evt); + if (result) { + return { + type: 'self', + groupKey: result.groupKey, + position: result.position, + }; + } + return; + }, + onClear: () => { + preview.remove(); + this.dropPreview.remove(); + }, + onDrop: result => { + if (!result) { + return; + } + if (result.type === 'out') { + result.callback(); + return; + } + if (result.type === 'self') { + this.host.props.view.rowMove( + row.rowId, + result.position, + fromGroup, + result.groupKey + ); + } + }, + }); + }; + + dropPreview = createDropPreview(); + + getInsertPosition = ( + evt: MouseEvent + ): + | { + groupKey: string | undefined; + position: InsertToPosition; + y: number; + width: number; + x: number; + } + | undefined => { + const y = evt.y; + const tableRect = this.host.getBoundingClientRect(); + const rows = this.host.querySelectorAll('data-view-table-row'); + if (!rows || !tableRect || y < tableRect.top) { + return; + } + for (let i = 0; i < rows.length; i++) { + const row = rows.item(i); + const rect = row.getBoundingClientRect(); + const mid = (rect.top + rect.bottom) / 2; + if (y < rect.bottom) { + return { + groupKey: row.groupKey, + position: { + id: row.dataset.rowId as string, + before: y < mid, + }, + y: y < mid ? rect.top : rect.bottom, + width: tableRect.width, + x: tableRect.left, + }; + } + } + return; + }; + + showIndicator = (evt: MouseEvent) => { + const position = this.getInsertPosition(evt); + if (position) { + this.dropPreview.display(position.x, position.y, position.width); + } else { + this.dropPreview.remove(); + } + return position; + }; + + constructor(private host: DataViewTable) { + this.host.addController(this); + } + + hostConnected() { + if (this.host.props.view.readonly$.value) { + return; + } + this.host.disposables.add( + this.host.props.handleEvent('dragStart', context => { + const event = context.get('pointerState').raw; + const target = event.target; + if ( + target instanceof Element && + this.host.contains(target) && + target.closest('.data-view-table-view-drag-handler') + ) { + event.preventDefault(); + const row = target.closest('data-view-table-row'); + if (row) { + getSelection()?.removeAllRanges(); + this.dragStart(row, event); + } + return true; + } + return false; + }) + ); + } +} + +const createDragPreview = (row: TableRow, x: number, y: number) => { + const div = document.createElement('div'); + const cloneRow = new TableRow(); + cloneRow.view = row.view; + cloneRow.rowIndex = row.rowIndex; + cloneRow.rowId = row.rowId; + cloneRow.dataViewEle = row.dataViewEle; + div.append(cloneRow); + div.className = 'with-data-view-css-variable'; + div.style.width = `${row.getBoundingClientRect().width}px`; + div.style.position = 'fixed'; + div.style.pointerEvents = 'none'; + div.style.backgroundColor = 'var(--affine-background-primary-color)'; + div.style.boxShadow = 'var(--affine-shadow-2)'; + div.style.left = `${x}px`; + div.style.top = `${y}px`; + div.style.zIndex = '9999'; + document.body.append(div); + return { + display(x: number, y: number) { + div.style.left = `${Math.round(x)}px`; + div.style.top = `${Math.round(y)}px`; + }, + remove() { + div.remove(); + }, + }; +}; +const createDropPreview = () => { + const div = document.createElement('div'); + div.dataset.isDropPreview = 'true'; + div.style.pointerEvents = 'none'; + div.style.position = 'fixed'; + div.style.zIndex = '9999'; + div.style.height = '2px'; + div.style.borderRadius = '1px'; + div.style.backgroundColor = 'var(--affine-primary-color)'; + div.style.boxShadow = '0px 0px 8px 0px rgba(30, 150, 235, 0.35)'; + return { + display(x: number, y: number, width: number) { + document.body.append(div); + div.style.left = `${x}px`; + div.style.top = `${y - 2}px`; + div.style.width = `${width}px`; + }, + remove() { + div.remove(); + }, + }; +}; diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/controller/hotkeys.ts b/packages/affine/microsheet-data-view/src/view-presets/table/controller/hotkeys.ts new file mode 100644 index 000000000000..3e6880224297 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/table/controller/hotkeys.ts @@ -0,0 +1,385 @@ +import type { ReactiveController } from 'lit'; + +import { popupTargetFromElement } from '@blocksuite/affine-components/context-menu'; + +import type { DataViewTable } from '../table-view.js'; + +import { popRowMenu } from '../components/menu.js'; +import { TableAreaSelection, TableRowSelection } from '../types.js'; + +export class TableHotkeysController implements ReactiveController { + get selectionController() { + return this.host.selectionController; + } + + constructor(private host: DataViewTable) { + this.host.addController(this); + } + + hostConnected() { + this.host.disposables.add( + this.host.props.bindHotkey({ + Backspace: () => { + const selection = this.selectionController.selection; + if (!selection) { + return; + } + if (TableRowSelection.is(selection)) { + const rows = TableRowSelection.rowsIds(selection); + this.selectionController.selection = undefined; + this.host.props.view.rowDelete(rows); + return; + } + const { + focus, + rowsSelection, + columnsSelection, + isEditing, + groupKey, + } = selection; + if (focus && !isEditing) { + if (rowsSelection && columnsSelection) { + // multi cell + for (let i = rowsSelection.start; i <= rowsSelection.end; i++) { + const { start, end } = columnsSelection; + for (let j = start; j <= end; j++) { + const container = this.selectionController.getCellContainer( + groupKey, + i, + j + ); + const rowId = container?.dataset.rowId; + const columnId = container?.dataset.columnId; + if (rowId && columnId) { + container?.column.valueSetFromString(rowId, ''); + } + } + } + } else { + // single cell + const container = this.selectionController.getCellContainer( + groupKey, + focus.rowIndex, + focus.columnIndex + ); + const rowId = container?.dataset.rowId; + const columnId = container?.dataset.columnId; + if (rowId && columnId) { + container?.column.valueSetFromString(rowId, ''); + } + } + } + }, + Escape: () => { + const selection = this.selectionController.selection; + if (!selection) { + return false; + } + if (TableRowSelection.is(selection)) { + const result = this.selectionController.rowsToArea( + selection.rows.map(v => v.id) + ); + if (result) { + this.selectionController.selection = TableAreaSelection.create({ + groupKey: result.groupKey, + focus: { + rowIndex: result.start, + columnIndex: 0, + }, + rowsSelection: { + start: result.start, + end: result.end, + }, + isEditing: false, + }); + } else { + this.selectionController.selection = undefined; + } + } else if (selection.isEditing) { + this.selectionController.selection = { + ...selection, + isEditing: false, + }; + } else { + const rows = this.selectionController.areaToRows(selection); + this.selectionController.rowSelectionChange({ + add: rows, + remove: [], + }); + } + return true; + }, + Enter: context => { + const selection = this.selectionController.selection; + if (!selection) { + return false; + } + if (TableRowSelection.is(selection)) { + const result = this.selectionController.rowsToArea( + selection.rows.map(v => v.id) + ); + if (result) { + this.selectionController.selection = TableAreaSelection.create({ + groupKey: result.groupKey, + focus: { + rowIndex: result.start, + columnIndex: 0, + }, + rowsSelection: { + start: result.start, + end: result.end, + }, + isEditing: false, + }); + } + } else if (selection.isEditing) { + return false; + } else { + this.selectionController.selection = { + ...selection, + isEditing: true, + }; + } + context.get('keyboardState').raw.preventDefault(); + return true; + }, + 'Shift-Enter': () => { + const selection = this.selectionController.selection; + if ( + !selection || + TableRowSelection.is(selection) || + selection.isEditing + ) { + return false; + } + const cell = this.selectionController.getCellContainer( + selection.groupKey, + selection.focus.rowIndex, + selection.focus.columnIndex + ); + if (cell) { + this.selectionController.insertRowAfter( + selection.groupKey, + cell.rowId + ); + } + return true; + }, + Tab: ctx => { + const selection = this.selectionController.selection; + if ( + !selection || + TableRowSelection.is(selection) || + selection.isEditing + ) { + return false; + } + ctx.get('keyboardState').raw.preventDefault(); + this.selectionController.focusToCell('right'); + return true; + }, + 'Shift-Tab': ctx => { + const selection = this.selectionController.selection; + if ( + !selection || + TableRowSelection.is(selection) || + selection.isEditing + ) { + return false; + } + ctx.get('keyboardState').raw.preventDefault(); + this.selectionController.focusToCell('left'); + return true; + }, + ArrowLeft: context => { + const selection = this.selectionController.selection; + if ( + !selection || + TableRowSelection.is(selection) || + selection.isEditing + ) { + return false; + } + this.selectionController.focusToCell('left'); + context.get('keyboardState').raw.preventDefault(); + return true; + }, + ArrowRight: context => { + const selection = this.selectionController.selection; + if ( + !selection || + TableRowSelection.is(selection) || + selection.isEditing + ) { + return false; + } + this.selectionController.focusToCell('right'); + context.get('keyboardState').raw.preventDefault(); + return true; + }, + ArrowUp: context => { + const selection = this.selectionController.selection; + if (!selection) { + return false; + } + + if (TableRowSelection.is(selection)) { + this.selectionController.navigateRowSelection('up', false); + } else if (selection.isEditing) { + return false; + } else { + this.selectionController.focusToCell('up'); + } + + context.get('keyboardState').raw.preventDefault(); + return true; + }, + ArrowDown: context => { + const selection = this.selectionController.selection; + if (!selection) { + return false; + } + + if (TableRowSelection.is(selection)) { + this.selectionController.navigateRowSelection('down', false); + } else if (selection.isEditing) { + return false; + } else { + this.selectionController.focusToCell('down'); + } + + context.get('keyboardState').raw.preventDefault(); + return true; + }, + + 'Shift-ArrowUp': context => { + const selection = this.selectionController.selection; + if (!selection) { + return false; + } + + if (TableRowSelection.is(selection)) { + this.selectionController.navigateRowSelection('up', true); + } else if (selection.isEditing) { + return false; + } else { + this.selectionController.selectionAreaUp(); + } + + context.get('keyboardState').raw.preventDefault(); + return true; + }, + + 'Shift-ArrowDown': context => { + const selection = this.selectionController.selection; + if (!selection) { + return false; + } + + if (TableRowSelection.is(selection)) { + this.selectionController.navigateRowSelection('down', true); + } else if (selection.isEditing) { + return false; + } else { + this.selectionController.selectionAreaDown(); + } + + context.get('keyboardState').raw.preventDefault(); + return true; + }, + + 'Shift-ArrowLeft': context => { + const selection = this.selectionController.selection; + if ( + !selection || + TableRowSelection.is(selection) || + selection.isEditing || + this.selectionController.isRowSelection() + ) { + return false; + } + + this.selectionController.selectionAreaLeft(); + + context.get('keyboardState').raw.preventDefault(); + return true; + }, + + 'Shift-ArrowRight': context => { + const selection = this.selectionController.selection; + if ( + !selection || + TableRowSelection.is(selection) || + selection.isEditing || + this.selectionController.isRowSelection() + ) { + return false; + } + + this.selectionController.selectionAreaRight(); + + context.get('keyboardState').raw.preventDefault(); + return true; + }, + + 'Mod-a': context => { + const selection = this.selectionController.selection; + if (TableRowSelection.is(selection)) { + return false; + } + if (selection?.isEditing) { + return true; + } + if (selection) { + context.get('keyboardState').raw.preventDefault(); + this.selectionController.selection = TableRowSelection.create({ + rows: + this.host.props.view.groupManager.groupsDataList$.value?.flatMap( + group => group.rows.map(id => ({ groupKey: group.key, id })) + ) ?? + this.host.props.view.rows$.value.map(id => ({ + groupKey: undefined, + id, + })), + }); + return true; + } + return; + }, + '/': context => { + const selection = this.selectionController.selection; + if (!selection) { + return; + } + if (TableRowSelection.is(selection)) { + // open multi-rows context-menu + return; + } + if (selection.isEditing) { + return; + } + const cell = this.selectionController.getCellContainer( + selection.groupKey, + selection.focus.rowIndex, + selection.focus.columnIndex + ); + if (cell) { + context.get('keyboardState').raw.preventDefault(); + const row = { + id: cell.rowId, + groupKey: selection.groupKey, + }; + this.selectionController.selection = TableRowSelection.create({ + rows: [row], + }); + popRowMenu( + this.host.props.dataViewEle, + popupTargetFromElement(cell), + this.selectionController + ); + } + }, + }) + ); + } +} diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/controller/selection.ts b/packages/affine/microsheet-data-view/src/view-presets/table/controller/selection.ts new file mode 100644 index 000000000000..aef8e4b47aef --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/table/controller/selection.ts @@ -0,0 +1,1140 @@ +import type { ReactiveController } from 'lit'; +import type { Ref } from 'lit/directives/ref.js'; + +import { ShadowlessElement } from '@blocksuite/block-std'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { effect } from '@preact/signals-core'; +import { css, html } from 'lit'; +import { property } from 'lit/decorators.js'; +import { createRef, ref } from 'lit/directives/ref.js'; + +import type { MicrosheetCellContainer } from '../cell.js'; +import type { TableRow } from '../row/row.js'; +import type { DataViewTable } from '../table-view.js'; + +import { startDrag } from '../../../core/utils/drag.js'; +import { autoScrollOnBoundary } from '../../../core/utils/frame-loop.js'; +import { + type CellFocus, + type MultiSelection, + RowWithGroup, + TableAreaSelection, + TableRowSelection, + type TableViewSelection, + type TableViewSelectionWithType, +} from '../types.js'; +import { + DragToFillElement, + fillSelectionWithFocusCellData, +} from './drag-to-fill.js'; + +export class TableSelectionController implements ReactiveController { + private _tableViewSelection?: TableViewSelectionWithType; + + private getFocusCellContainer = () => { + if ( + !this._tableViewSelection || + this._tableViewSelection.selectionType !== 'area' + ) + return null; + const { groupKey, focus } = this._tableViewSelection; + + const dragStartCell = this.getCellContainer( + groupKey, + focus.rowIndex, + focus.columnIndex + ); + return dragStartCell ?? null; + }; + + __dragToFillElement = new DragToFillElement(); + + __selectionElement; + + selectionStyleUpdateTask = 0; + + private get areaSelectionElement() { + return this.__selectionElement.selectionRef.value; + } + + get dragToFillDraggable() { + return this.__dragToFillElement.dragToFillRef.value; + } + + private get focusSelectionElement() { + return this.__selectionElement.focusRef.value; + } + + get selection(): TableViewSelectionWithType | undefined { + return this._tableViewSelection; + } + + set selection(data: TableViewSelection | undefined) { + if (!data) { + this.clearSelection(); + return; + } + const selection: TableViewSelectionWithType = { + ...data, + viewId: this.view.id, + type: 'table', + }; + if (selection.selectionType === 'area' && selection.isEditing) { + const focus = selection.focus; + const container = this.getCellContainer( + selection.groupKey, + focus.rowIndex, + focus.columnIndex + ); + const cell = container?.cell; + const isEditing = cell ? cell.beforeEnterEditMode() : true; + this.host.props.setSelection({ + ...selection, + isEditing, + }); + } else { + this.host.props.setSelection(selection); + } + } + + get tableContainer() { + return this.host.querySelector('.affine-microsheet-table-container'); + } + + get view() { + return this.host.props.view; + } + + get viewData() { + return this.view; + } + + constructor(public host: DataViewTable) { + host.addController(this); + this.__selectionElement = new SelectionElement(); + this.__selectionElement.controller = this; + } + + private clearSelection() { + this.host.props.setSelection(); + } + + private handleDragEvent() { + this.host.disposables.add( + this.host.props.handleEvent('dragStart', context => { + if (this.host.props.view.readonly$.value) { + return; + } + const event = context.get('pointerState').raw; + const target = event.target; + if (target instanceof HTMLElement) { + const [cell, fillValues] = this.resolveDragStartTarget(target); + + if (cell) { + const selection = this.selection; + if ( + selection && + selection.selectionType === 'area' && + selection.isEditing && + selection.focus.rowIndex === cell.rowIndex && + selection.focus.columnIndex === cell.columnIndex + ) { + return false; + } + this.startDrag(event, cell, fillValues); + event.preventDefault(); + return true; + } + return false; + } + return false; + }) + ); + } + + private handleSelectionChange() { + this.host.disposables.add( + this.host.props.selection$.subscribe(tableSelection => { + if (!this.isValidSelection(tableSelection)) { + this.selection = undefined; + return; + } + const old = + this._tableViewSelection?.selectionType === 'area' + ? this._tableViewSelection + : undefined; + const newSelection = + tableSelection?.selectionType === 'area' ? tableSelection : undefined; + if ( + old?.focus.rowIndex !== newSelection?.focus.rowIndex || + old?.focus.columnIndex !== newSelection?.focus.columnIndex + ) { + requestAnimationFrame(() => { + this.scrollToFocus(); + }); + } + + if ( + this.isRowSelection() && + (old?.rowsSelection?.start !== newSelection?.rowsSelection?.start || + old?.rowsSelection?.end !== newSelection?.rowsSelection?.end) + ) { + requestAnimationFrame(() => { + this.scrollToAreaSelection(); + }); + } + + if (old) { + const container = this.getCellContainer( + old.groupKey, + old.focus.rowIndex, + old.focus.columnIndex + ); + if (container) { + const cell = container.cell; + if (old.isEditing) { + requestAnimationFrame(() => { + cell?.onExitEditMode(); + }); + cell?.blurCell(); + container.isEditing = false; + } + } + } + this._tableViewSelection = tableSelection; + + if (newSelection) { + const container = this.getCellContainer( + newSelection.groupKey, + newSelection.focus.rowIndex, + newSelection.focus.columnIndex + ); + if (container) { + const cell = container.cell; + if (newSelection.isEditing) { + cell?.onEnterEditMode(); + container.isEditing = true; + cell?.focusCell(); + } + } + } + }) + ); + } + + private insertTo( + groupKey: string | undefined, + rowId: string, + before: boolean + ) { + const id = this.view.rowAdd({ before, id: rowId }); + if (groupKey != null) { + this.view.groupManager.moveCardTo(id, undefined, groupKey, { + before, + id: rowId, + }); + } + const rows = + groupKey != null + ? this.view.groupManager.groupDataMap$.value?.[groupKey].rows + : this.view.rows$.value; + requestAnimationFrame(() => { + const index = this.host.props.view.properties$.value.findIndex( + v => v.type$.value === 'title' + ); + this.selection = TableAreaSelection.create({ + groupKey: groupKey, + focus: { + rowIndex: rows?.findIndex(v => v === id) ?? 0, + columnIndex: index, + }, + isEditing: true, + }); + }); + } + + private resolveDragStartTarget( + target: HTMLElement + ): [cell: MicrosheetCellContainer | null, fillValues: boolean] { + let cell: MicrosheetCellContainer | null; + const fillValues = !!target.dataset.dragToFill; + if (fillValues) { + const focusCellContainer = this.getFocusCellContainer(); + cell = focusCellContainer ?? null; + } else { + cell = target.closest('affine-microsheet-cell-container'); + } + return [cell, fillValues]; + } + + private scrollToAreaSelection() { + this.areaSelectionElement?.scrollIntoView({ + block: 'nearest', + inline: 'nearest', + }); + } + + private scrollToFocus() { + this.focusSelectionElement?.scrollIntoView({ + block: 'nearest', + inline: 'nearest', + }); + } + + areaToRows(selection: TableAreaSelection) { + const rows = this.rows(selection.groupKey) ?? []; + const ids = Array.from({ + length: selection.rowsSelection.end - selection.rowsSelection.start + 1, + }) + .map((_, index) => index + selection.rowsSelection.start) + .map(row => rows[row]?.rowId); + return ids.map(id => ({ id, groupKey: selection.groupKey })); + } + + cellPosition(groupKey: string | undefined) { + const rows = this.rows(groupKey); + const cells = rows + ?.item(0) + .querySelectorAll('affine-microsheet-cell-container'); + + return (x1: number, x2: number, y1: number, y2: number) => { + const rowOffsets: number[] = Array.from(rows ?? []).map( + v => v.getBoundingClientRect().top + ); + const columnOffsets: number[] = Array.from(cells ?? []).map( + v => v.getBoundingClientRect().left + ); + const [startX, endX] = x1 < x2 ? [x1, x2] : [x2, x1]; + const [startY, endY] = y1 < y2 ? [y1, y2] : [y2, y1]; + const row: MultiSelection = { + start: 0, + end: 0, + }; + const column: MultiSelection = { + start: 0, + end: 0, + }; + for (let i = 0; i < rowOffsets.length; i++) { + const offset = rowOffsets[i]; + if (offset < startY) { + row.start = i; + } + if (offset < endY) { + row.end = i; + } + } + for (let i = 0; i < columnOffsets.length; i++) { + const offset = columnOffsets[i]; + if (offset < startX) { + column.start = i; + } + if (offset < endX) { + column.end = i; + } + } + return { + row, + column, + }; + }; + } + + deleteRow(rowId: string) { + this.view.rowDelete([rowId]); + this.focusToCell('up'); + } + + focusFirstCell() { + this.selection = TableAreaSelection.create({ + focus: { + rowIndex: 0, + columnIndex: 0, + }, + isEditing: false, + }); + } + + focusToArea(selection: TableAreaSelection) { + return { + ...selection, + rowsSelection: selection.rowsSelection ?? { + start: selection.focus.rowIndex, + end: selection.focus.rowIndex, + }, + columnsSelection: selection.columnsSelection ?? { + start: selection.focus.columnIndex, + end: selection.focus.columnIndex, + }, + isEditing: false, + } satisfies TableAreaSelection; + } + + focusToCell(position: 'left' | 'right' | 'up' | 'down') { + if (!this.selection || this.selection.selectionType !== 'area') { + return; + } + const cell = this.getCellContainer( + this.selection.groupKey, + this.selection.focus.rowIndex, + this.selection.focus.columnIndex + ); + if (!cell) { + return; + } + const row = cell.closest('data-view-table-row'); + const rows = Array.from( + row + ?.closest('.affine-microsheet-table-container') + ?.querySelectorAll('data-view-table-row') ?? [] + ); + const cells = Array.from( + row?.querySelectorAll('affine-microsheet-cell-container') ?? [] + ); + if (!row || !rows || !cells) { + return; + } + let rowIndex = rows.indexOf(row); + let columnIndex = cells.indexOf(cell); + if (position === 'left') { + if (columnIndex === 0) { + columnIndex = cells.length - 1; + rowIndex--; + } else { + columnIndex--; + } + } + if (position === 'right') { + if (columnIndex === cells.length - 1) { + columnIndex = 0; + rowIndex++; + } else { + columnIndex++; + } + } + if (position === 'up') { + if (rowIndex === 0) { + // + } else { + rowIndex--; + } + } + if (position === 'down') { + if (rowIndex === rows.length - 1) { + // + } else { + rowIndex++; + } + } + rows[rowIndex] + ?.querySelectorAll('affine-microsheet-cell-container') + ?.item(columnIndex) + ?.selectCurrentCell(false); + } + + getCellContainer( + groupKey: string | undefined, + rowIndex: number, + columnIndex: number + ): MicrosheetCellContainer | undefined { + const row = this.rows(groupKey)?.item(rowIndex); + return row + ?.querySelectorAll('affine-microsheet-cell-container') + .item(columnIndex); + } + + getGroup(groupKey: string | undefined) { + const container = + groupKey != null + ? this.tableContainer?.querySelector( + `affine-microsheet-data-view-table-group[data-group-key="${groupKey}"]` + ) + : this.tableContainer; + return container ?? null; + } + + getRect( + groupKey: string | undefined, + top: number, + bottom: number, + left: number, + right: number + ): + | undefined + | { + top: number; + left: number; + width: number; + height: number; + scale: number; + } { + const rows = this.rows(groupKey); + const topRow = rows?.item(top); + const bottomRow = rows?.item(bottom); + if (!topRow || !bottomRow) { + return; + } + const topCells = topRow.querySelectorAll( + 'affine-microsheet-cell-container' + ); + const leftCell = topCells.item(left); + const rightCell = topCells.item(right); + if (!leftCell || !rightCell) { + return; + } + const leftRect = leftCell.getBoundingClientRect(); + const scale = leftRect.width / leftCell.column.width$.value; + return { + top: leftRect.top / scale, + left: leftRect.left / scale, + width: (rightCell.getBoundingClientRect().right - leftRect.left) / scale, + height: (bottomRow.getBoundingClientRect().bottom - leftRect.top) / scale, + scale, + }; + } + + getRow(groupKey: string | undefined, rowId: string) { + return this.getGroup(groupKey)?.querySelector( + `data-view-table-row[data-row-id='${rowId}']` + ); + } + + getSelectionAreaBorder(position: 'left' | 'right' | 'top' | 'bottom') { + return this.__selectionElement.selectionRef.value?.querySelector( + `.area-border.area-${position}` + ); + } + + hostConnected() { + requestAnimationFrame(() => { + this.tableContainer?.append(this.__selectionElement); + this.tableContainer?.append(this.__dragToFillElement); + }); + this.handleDragEvent(); + this.handleSelectionChange(); + } + + insertRowAfter(groupKey: string | undefined, rowId: string) { + this.insertTo(groupKey, rowId, false); + } + + insertRowBefore(groupKey: string | undefined, rowId: string) { + this.insertTo(groupKey, rowId, true); + } + + isRowSelection() { + return this.selection?.selectionType === 'row'; + } + + isValidSelection(selection?: TableViewSelectionWithType): boolean { + if (!selection || selection.selectionType === 'row') { + return true; + } + if (selection.focus.rowIndex > this.view.rows$.value.length - 1) { + this.selection = undefined; + return false; + } + if (selection.focus.columnIndex > this.view.propertyIds$.value.length - 1) { + this.selection = undefined; + return false; + } + return true; + } + + navigateRowSelection(direction: 'up' | 'down', append = false) { + if (!TableRowSelection.is(this.selection)) return; + const rows = this.selection.rows; + const lastRow = rows[rows.length - 1]; + const lastRowIndex = + ( + this.getGroup(lastRow.groupKey)?.querySelector( + `data-view-table-row[data-row-id='${lastRow.id}']` + ) as TableRow | null + )?.rowIndex ?? 0; + const getRowByIndex = (index: number) => { + const tableRow = this.rows(lastRow.groupKey)?.item(index); + if (!tableRow) { + return; + } + return { + id: tableRow.rowId, + groupKey: lastRow.groupKey, + }; + }; + const prevRow = getRowByIndex(lastRowIndex - 1); + const nextRow = getRowByIndex(lastRowIndex + 1); + const includes = (row: RowWithGroup) => { + if (!row) { + return false; + } + return rows.some(r => RowWithGroup.equal(r, row)); + }; + if (append) { + const addList: RowWithGroup[] = []; + const removeList: RowWithGroup[] = []; + if (direction === 'up' && prevRow != null) { + if (includes(prevRow)) { + removeList.push(lastRow); + } else { + addList.push(prevRow); + } + } + if (direction === 'down' && nextRow != null) { + if (includes(nextRow)) { + removeList.push(lastRow); + } else { + addList.push(nextRow); + } + } + this.rowSelectionChange({ add: addList, remove: removeList }); + } else { + const target = direction === 'up' ? prevRow : nextRow; + if (target != null) { + this.selection = TableRowSelection.create({ + rows: [target], + }); + } + } + } + + rows(groupKey: string | undefined) { + const container = + groupKey != null + ? this.tableContainer?.querySelector( + `affine-microsheet-data-view-table-group[data-group-key="${groupKey}"]` + ) + : this.tableContainer; + return container?.querySelectorAll('data-view-table-row'); + } + + rowSelectionChange({ + add, + remove, + }: { + add: RowWithGroup[]; + remove: RowWithGroup[]; + }) { + const key = (r: RowWithGroup) => `${r.id}.${r.groupKey ? r.groupKey : ''}`; + const rows = new Set( + TableRowSelection.rows(this.selection).map(r => key(r)) + ); + remove.forEach(row => rows.delete(key(row))); + add.forEach(row => rows.add(key(row))); + const result = [...rows] + .map(r => r.split('.')) + .map(([id, groupKey]) => ({ + id, + groupKey: groupKey ? groupKey : undefined, + })); + this.selection = TableRowSelection.create({ + rows: result, + }); + } + + rowsToArea( + rows: string[] + ): { start: number; end: number; groupKey?: string } | undefined { + let groupKey: string | undefined = undefined; + let minIndex: number | undefined = undefined; + let maxIndex: number | undefined = undefined; + const set = new Set(rows); + if (!this.tableContainer) return; + for (const row of this.tableContainer + ?.querySelectorAll('data-view-table-row') + .values() ?? []) { + if (!set.has(row.rowId)) { + continue; + } + minIndex = + minIndex != null ? Math.min(minIndex, row.rowIndex) : row.rowIndex; + maxIndex = + maxIndex != null ? Math.max(maxIndex, row.rowIndex) : row.rowIndex; + if (groupKey == null) { + groupKey = row.groupKey; + } else if (groupKey !== row.groupKey) { + return; + } + } + if (minIndex == null || maxIndex == null) { + return; + } + return { + groupKey, + start: minIndex, + end: maxIndex, + }; + } + + selectionAreaDown() { + const selection = this.selection; + if (!selection || selection.selectionType !== 'area') { + return; + } + const newSelection = this.focusToArea(selection); + if (newSelection.rowsSelection.start === newSelection.focus.rowIndex) { + newSelection.rowsSelection.end = Math.min( + (this.rows(newSelection.groupKey)?.length ?? 0) - 1, + newSelection.rowsSelection.end + 1 + ); + requestAnimationFrame(() => { + this.getSelectionAreaBorder('bottom')?.scrollIntoView({ + block: 'nearest', + inline: 'nearest', + behavior: 'smooth', + }); + }); + } else { + newSelection.rowsSelection.start += 1; + requestAnimationFrame(() => { + this.getSelectionAreaBorder('top')?.scrollIntoView({ + block: 'nearest', + inline: 'nearest', + behavior: 'smooth', + }); + }); + } + this.selection = newSelection; + } + + selectionAreaLeft() { + const selection = this.selection; + if (!selection || selection.selectionType !== 'area') { + return; + } + const newSelection = this.focusToArea(selection); + if (newSelection.columnsSelection.end === newSelection.focus.columnIndex) { + newSelection.columnsSelection.start = Math.max( + 0, + newSelection.columnsSelection.start - 1 + ); + requestAnimationFrame(() => { + this.getSelectionAreaBorder('left')?.scrollIntoView({ + block: 'nearest', + inline: 'nearest', + behavior: 'smooth', + }); + }); + } else { + newSelection.columnsSelection.end -= 1; + requestAnimationFrame(() => { + this.getSelectionAreaBorder('right')?.scrollIntoView({ + block: 'nearest', + inline: 'nearest', + behavior: 'smooth', + }); + }); + } + this.selection = newSelection; + } + + selectionAreaRight() { + const selection = this.selection; + if (!selection || selection.selectionType !== 'area') { + return; + } + const newSelection = this.focusToArea(selection); + if ( + newSelection.columnsSelection.start === newSelection.focus.columnIndex + ) { + const max = + (this.rows(newSelection.groupKey) + ?.item(0) + .querySelectorAll('affine-microsheet-cell-container').length ?? 0) - + 1; + newSelection.columnsSelection.end = Math.min( + max, + newSelection.columnsSelection.end + 1 + ); + requestAnimationFrame(() => { + this.getSelectionAreaBorder('right')?.scrollIntoView({ + block: 'nearest', + inline: 'nearest', + behavior: 'smooth', + }); + }); + } else { + newSelection.columnsSelection.start += 1; + requestAnimationFrame(() => { + this.getSelectionAreaBorder('left')?.scrollIntoView({ + block: 'nearest', + inline: 'nearest', + behavior: 'smooth', + }); + }); + } + this.selection = newSelection; + } + + selectionAreaUp() { + const selection = this.selection; + if (!selection || selection.selectionType !== 'area') { + return; + } + const newSelection = this.focusToArea(selection); + if (newSelection.rowsSelection.end === newSelection.focus.rowIndex) { + newSelection.rowsSelection.start = Math.max( + 0, + newSelection.rowsSelection.start - 1 + ); + requestAnimationFrame(() => { + this.getSelectionAreaBorder('top')?.scrollIntoView({ + block: 'nearest', + inline: 'nearest', + behavior: 'smooth', + }); + }); + } else { + newSelection.rowsSelection.end -= 1; + requestAnimationFrame(() => { + this.getSelectionAreaBorder('bottom')?.scrollIntoView({ + block: 'nearest', + inline: 'nearest', + behavior: 'smooth', + }); + }); + } + this.selection = newSelection; + } + + startDrag( + evt: PointerEvent, + cell: MicrosheetCellContainer, + fillValues?: boolean + ) { + const groupKey = cell.closest('affine-microsheet-data-view-table-group') + ?.group?.key; + const table = this.tableContainer; + const scrollContainer = table?.parentElement; + if (!table || !scrollContainer) { + return; + } + const tableRect = table.getBoundingClientRect(); + const startOffsetX = evt.x - tableRect.left; + const startOffsetY = evt.y - tableRect.top; + const offsetToSelection = this.cellPosition(groupKey); + const select = (selection: { + row: MultiSelection; + column: MultiSelection; + }) => { + this.selection = TableAreaSelection.create({ + groupKey: groupKey, + rowsSelection: selection.row, + columnsSelection: selection.column, + focus: { + rowIndex: cell.rowIndex, + columnIndex: cell.columnIndex, + }, + isEditing: false, + }); + }; + const cancelScroll = autoScrollOnBoundary(scrollContainer, { + onScroll() { + drag.move({ x: drag.last.x, y: drag.last.y }); + }, + }); + const drag = startDrag< + | { + row: MultiSelection; + column: MultiSelection; + } + | undefined, + { + x: number; + y: number; + } + >(evt, { + transform: evt => ({ + x: evt.x, + y: evt.y, + }), + onDrag: () => { + if (fillValues) this.__dragToFillElement.dragging = true; + return undefined; + }, + onMove: ({ x, y }) => { + if (!table) return; + const tableRect = table.getBoundingClientRect(); + const startX = tableRect.left + startOffsetX; + const startY = tableRect.top + startOffsetY; + const selection = offsetToSelection(startX, x, startY, y); + + if (fillValues) + selection.column = { + start: cell.columnIndex, + end: cell.columnIndex, + }; + + select(selection); + return selection; + }, + onDrop: selection => { + if (!selection) { + return; + } + select(selection); + if (fillValues && this.selection) { + this.__dragToFillElement.dragging = false; + fillSelectionWithFocusCellData( + this.host, + TableAreaSelection.create({ + groupKey: groupKey, + rowsSelection: selection.row, + columnsSelection: selection.column, + focus: { + rowIndex: cell.rowIndex, + columnIndex: cell.columnIndex, + }, + isEditing: false, + }) + ); + } + }, + onClear: () => { + cancelScroll(); + }, + }); + } + + toggleRow(rowId: string, groupKey?: string) { + const row = { + id: rowId, + groupKey, + }; + const isSelected = TableRowSelection.includes(this.selection, row); + this.rowSelectionChange({ + add: isSelected ? [] : [row], + remove: isSelected ? [row] : [], + }); + } +} + +export class SelectionElement extends WithDisposable(ShadowlessElement) { + static override styles = css` + .microsheet-selection { + position: absolute; + z-index: 2; + box-sizing: border-box; + background: var(--affine-primary-color-04); + pointer-events: none; + display: none; + } + + .microsheet-focus { + position: absolute; + width: 100%; + z-index: 2; + box-sizing: border-box; + border: 1px solid var(--affine-primary-color); + border-radius: 2px; + pointer-events: none; + display: none; + outline: none; + } + + .area-border { + position: absolute; + pointer-events: none; + } + .area-left { + left: 0; + height: 100%; + width: 1px; + } + .area-right { + right: 0; + height: 100%; + width: 1px; + } + .area-top { + top: 0; + width: 100%; + height: 1px; + } + .area-bottom { + bottom: 0; + width: 100%; + height: 1px; + } + @media print { + data-view-table-selection { + display: none; + } + } + `; + + focusRef: Ref = createRef(); + + preTask = 0; + + selectionRef: Ref = createRef(); + + get selection$() { + return this.controller.host.props.selection$; + } + + clearAreaStyle() { + const div = this.selectionRef.value; + if (!div) return; + div.style.display = 'none'; + } + + clearFocusStyle() { + const div = this.focusRef.value; + const dragToFill = this.controller.dragToFillDraggable; + if (!div || !dragToFill) return; + div.style.display = 'none'; + dragToFill.style.display = 'none'; + } + + override connectedCallback() { + super.connectedCallback(); + this.disposables.add( + effect(() => { + this.startUpdate(this.selection$.value); + }) + ); + } + + override render() { + return html` +
+
+
+
+
+
+
+ `; + } + + startUpdate(selection?: TableViewSelection) { + if (this.preTask) { + cancelAnimationFrame(this.preTask); + this.preTask = 0; + } + if ( + selection?.selectionType === 'area' && + !this.controller.host.props.view.readonly$.value + ) { + this.updateAreaSelectionStyle( + selection.groupKey, + selection.rowsSelection, + selection.columnsSelection + ); + + const columnSelection = selection.columnsSelection; + const rowSelection = selection.rowsSelection; + + const isSingleRowSelection = rowSelection.end - rowSelection.start === 0; + const isSingleColumnSelection = + columnSelection.end - columnSelection.start === 0; + + const isDragElemDragging = this.controller.__dragToFillElement.dragging; + const isEditing = selection.isEditing; + + const showDragToFillHandle = + !isEditing && + (isDragElemDragging || isSingleRowSelection) && + isSingleColumnSelection; + + this.updateFocusSelectionStyle( + selection.groupKey, + selection.focus, + isEditing, + showDragToFillHandle + ); + this.preTask = requestAnimationFrame(() => + this.startUpdate(this.selection$.value) + ); + } else { + this.clearFocusStyle(); + this.clearAreaStyle(); + } + } + + updateAreaSelectionStyle( + groupKey: string | undefined, + rowSelection: MultiSelection, + columnSelection: MultiSelection + ) { + const div = this.selectionRef.value; + if (!div) return; + const tableContainer = this.controller.tableContainer; + if (!tableContainer) return; + const tableRect = tableContainer.getBoundingClientRect(); + const rect = this.controller.getRect( + groupKey, + rowSelection?.start ?? 0, + rowSelection?.end ?? this.controller.view.rows$.value.length - 1, + columnSelection?.start ?? 0, + columnSelection?.end ?? this.controller.view.properties$.value.length - 1 + ); + if (!rect) { + this.clearAreaStyle(); + return; + } + const { left, top, width, height, scale } = rect; + div.style.left = `${left - tableRect.left / scale}px`; + div.style.top = `${top - tableRect.top / scale}px`; + div.style.width = `${width}px`; + div.style.height = `${height}px`; + div.style.display = 'block'; + } + + updateFocusSelectionStyle( + groupKey: string | undefined, + focus: CellFocus, + isEditing: boolean, + showDragToFillHandle = false + ) { + const div = this.focusRef.value; + const dragToFill = this.controller.dragToFillDraggable; + if (!div || !dragToFill) return; + // Check if row is removed. + const rows = this.controller.rows(groupKey) ?? []; + if (rows.length <= focus.rowIndex) return; + + const rect = this.controller.getRect( + groupKey, + focus.rowIndex, + focus.rowIndex, + focus.columnIndex, + focus.columnIndex + ); + if (!rect) { + this.clearFocusStyle(); + return; + } + const { left, top, width, height, scale } = rect; + const tableContainer = this.controller.tableContainer; + if (!tableContainer) return; + const tableRect = tableContainer?.getBoundingClientRect(); + if (!tableRect) { + this.clearFocusStyle(); + return; + } + + const x = left - tableRect.left / scale; + const y = top - 1 - tableRect.top / scale; + const w = width + 1; + const h = height + 1; + div.style.left = `${x}px`; + div.style.top = `${y}px`; + div.style.width = `${w}px`; + div.style.height = `${h}px`; + div.style.borderColor = 'var(--affine-primary-color)'; + div.style.borderStyle = this.controller.__dragToFillElement.dragging + ? 'dashed' + : 'solid'; + div.style.boxShadow = isEditing + ? '0px 0px 0px 2px rgba(30, 150, 235, 0.30)' + : 'unset'; + div.style.display = 'block'; + + dragToFill.style.left = `${x + w}px`; + dragToFill.style.top = `${y + h}px`; + dragToFill.style.display = showDragToFillHandle ? 'block' : 'none'; + } + + @property({ attribute: false }) + accessor controller!: TableSelectionController; +} diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/define.ts b/packages/affine/microsheet-data-view/src/view-presets/table/define.ts new file mode 100644 index 000000000000..f140f0193516 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/table/define.ts @@ -0,0 +1,51 @@ +import type { FilterGroup } from '../../core/common/ast.js'; +import type { GroupBy, GroupProperty, Sort } from '../../core/common/types.js'; + +import { type BasicViewDataType, viewType } from '../../core/view/data-view.js'; +import { TableSingleView } from './table-view-manager.js'; + +export const tableViewType = viewType('table'); + +export type TableViewColumn = { + id: string; + width: number; + statCalcType?: string; + hide?: boolean; +}; +type DataType = { + columns: TableViewColumn[]; + filter: FilterGroup; + groupBy?: GroupBy; + groupProperties?: GroupProperty[]; + sort?: Sort; + header?: { + titleColumn?: string; + iconColumn?: string; + imageColumn?: string; + }; +}; +export type TableViewData = BasicViewDataType< + typeof tableViewType.type, + DataType +>; +export const tableViewModel = tableViewType.createModel({ + defaultName: 'Table View', + dataViewManager: TableSingleView, + defaultData: viewManager => { + return { + mode: 'table', + columns: [], + filter: { + type: 'group', + op: 'and', + conditions: [], + }, + header: { + titleColumn: viewManager.dataSource.properties$.value.find( + id => viewManager.dataSource.propertyTypeGet(id) === 'title' + ), + iconColumn: 'type', + }, + }; + }, +}); diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/group.ts b/packages/affine/microsheet-data-view/src/view-presets/table/group.ts new file mode 100644 index 000000000000..cffb2ebaac40 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/table/group.ts @@ -0,0 +1,235 @@ +import { + menu, + popFilterableSimpleMenu, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { PlusIcon } from '@blocksuite/icons/lit'; +import { css, html, type PropertyValues } from 'lit'; +import { property } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import type { GroupData } from '../../core/common/group-by/helper.js'; +import type { DataViewRenderer } from '../../core/data-view.js'; +import type { DataViewTable } from './table-view.js'; +import type { TableSingleView } from './table-view-manager.js'; + +import { GroupTitle } from '../../core/common/group-by/group-title.js'; +import { LEFT_TOOL_BAR_WIDTH } from './consts.js'; +import { TableAreaSelection } from './types.js'; + +const styles = css` + affine-microsheet-data-view-table-group:hover .group-header-op { + visibility: visible; + opacity: 1; + } + .microsheet-data-view-table-group-add-row { + display: flex; + width: 100%; + height: 28px; + position: relative; + z-index: 0; + cursor: pointer; + transition: opacity 0.2s ease-in-out; + padding: 4px 8px; + border-bottom: 1px solid var(--affine-border-color); + } + + @media print { + .microsheet-data-view-table-group-add-row { + display: none; + } + } + + .microsheet-data-view-table-group-add-row-button { + position: sticky; + left: ${8 + LEFT_TOOL_BAR_WIDTH}px; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + user-select: none; + font-size: 12px; + line-height: 20px; + color: var(--affine-text-secondary-color); + } +`; + +export class TableGroup extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = styles; + + private clickAddRow = () => { + this.view.rowAdd('end', this.group?.key); + requestAnimationFrame(() => { + const selectionController = this.viewEle.selectionController; + const index = this.view.properties$.value.findIndex( + v => v.type$.value === 'title' + ); + selectionController.selection = TableAreaSelection.create({ + groupKey: this.group?.key, + focus: { + rowIndex: this.rows.length - 1, + columnIndex: index, + }, + isEditing: true, + }); + }); + }; + + private clickAddRowInStart = () => { + this.view.rowAdd('start', this.group?.key); + requestAnimationFrame(() => { + const selectionController = this.viewEle.selectionController; + const index = this.view.properties$.value.findIndex( + v => v.type$.value === 'title' + ); + selectionController.selection = TableAreaSelection.create({ + groupKey: this.group?.key, + focus: { + rowIndex: 0, + columnIndex: index, + }, + isEditing: true, + }); + }); + }; + + private clickGroupOptions = (e: MouseEvent) => { + const group = this.group; + if (!group) { + return; + } + const ele = e.currentTarget as HTMLElement; + popFilterableSimpleMenu(popupTargetFromElement(ele), [ + menu.action({ + name: 'Ungroup', + hide: () => group.value == null, + select: () => { + group.rows.forEach(id => { + group.manager.removeFromGroup(id, group.key); + }); + }, + }), + menu.action({ + name: 'Delete Cards', + select: () => { + this.view.rowDelete(group.rows); + }, + }), + ]); + }; + + private renderGroupHeader = () => { + if (!this.group) { + return null; + } + return html` +
+ ${GroupTitle(this.group, { + readonly: this.view.readonly$.value, + clickAdd: this.clickAddRowInStart, + clickOps: this.clickGroupOptions, + })} +
+ `; + }; + + get rows() { + return this.group?.rows ?? this.view.rows$.value; + } + + private renderRows(ids: string[]) { + return html` + +
+ ${repeat( + ids, + id => id, + (id, idx) => { + return html``; + } + )} +
+ `; + return html` + +
+ ${repeat( + ids, + id => id, + (id, idx) => { + return html``; + } + )} +
+ ${this.view.readonly$.value + ? null + : html`
+
+ ${PlusIcon()}New Record +
+
`} + + + `; + } + + override render() { + return this.renderRows(this.rows); + } + + protected override updated(_changedProperties: PropertyValues) { + super.updated(_changedProperties); + this.querySelectorAll('microsheet-data-view-table-row').forEach(ele => { + ele.requestUpdate(); + }); + } + + @property({ attribute: false }) + accessor dataViewEle!: DataViewRenderer; + + @property({ attribute: false }) + accessor group: GroupData | undefined = undefined; + + @property({ attribute: false }) + accessor view!: TableSingleView; + + @property({ attribute: false }) + accessor viewEle!: DataViewTable; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-microsheet-data-view-table-group': TableGroup; + } +} diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/header/column-header.ts b/packages/affine/microsheet-data-view/src/view-presets/table/header/column-header.ts new file mode 100644 index 000000000000..41012b4e3cae --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/table/header/column-header.ts @@ -0,0 +1,139 @@ +import { getScrollContainer } from '@blocksuite/affine-shared/utils'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { PlusIcon } from '@blocksuite/icons/lit'; +import { autoUpdate } from '@floating-ui/dom'; +import { nothing, type TemplateResult } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { html } from 'lit/static-html.js'; + +import type { TableGroup } from '../group.js'; +import type { TableSingleView } from '../table-view-manager.js'; + +import { styles } from './styles.js'; + +export class MicrosheetColumnHeader extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = styles; + + private _onAddColumn = (e: MouseEvent) => { + if (this.readonly) return; + this.tableViewManager.propertyAdd('end'); + const ele = e.currentTarget as HTMLElement; + requestAnimationFrame(() => { + this.editLastColumnTitle(); + ele.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + }); + }; + + editLastColumnTitle = () => { + const columns = this.querySelectorAll('affine-microsheet-header-column'); + const column = columns.item(columns.length - 1); + column.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + column.editTitle(); + }; + + preMove = 0; + + private get readonly() { + return this.tableViewManager.readonly$.value; + } + + private autoSetHeaderPosition( + group: TableGroup, + scrollContainer: HTMLElement + ) { + const referenceRect = group.getBoundingClientRect(); + const floatingRect = this.getBoundingClientRect(); + const rootRect = scrollContainer.getBoundingClientRect(); + let moveX = 0; + if (rootRect.top > referenceRect.top) { + moveX = + Math.min(referenceRect.bottom - floatingRect.height, rootRect.top) - + referenceRect.top; + } + if (moveX === 0 && this.preMove === 0) { + return; + } + this.preMove = moveX; + this.style.transform = `translate3d(0,${moveX / this.getScale()}px,0)`; + } + + override connectedCallback() { + super.connectedCallback(); + const scrollContainer = getScrollContainer( + this.closest('affine-microsheet-data-view-renderer')! + ); + const group = this.closest('affine-microsheet-data-view-table-group'); + if (group) { + const cancel = autoUpdate(group, this, () => { + if (!scrollContainer) { + return; + } + this.autoSetHeaderPosition(group, scrollContainer); + }); + this.disposables.add(cancel); + } + } + + getScale() { + return this.scaleDiv?.getBoundingClientRect().width ?? 1; + } + + override render() { + return html` + ${this.renderGroupHeader?.()} +
+ ${this.readonly + ? nothing + : html`
`} + ${repeat( + this.tableViewManager.properties$.value, + column => column.id, + (column, index) => { + const style = styleMap({ + width: `${column.width$.value}px`, + border: index === 0 ? 'none' : undefined, + }); + return html` `; + } + )} +
+
+ ${PlusIcon()} +
+
+
+ `; + } + + @property({ attribute: false }) + accessor renderGroupHeader: (() => TemplateResult) | undefined; + + @query('.scale-div') + accessor scaleDiv!: HTMLDivElement; + + @property({ attribute: false }) + accessor tableViewManager!: TableSingleView; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-microsheet-column-header': MicrosheetColumnHeader; + } +} diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/header/column-renderer.ts b/packages/affine/microsheet-data-view/src/view-presets/table/header/column-renderer.ts new file mode 100644 index 000000000000..0c666cac8157 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/table/header/column-renderer.ts @@ -0,0 +1,87 @@ +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { css } from 'lit'; +import { property } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { html } from 'lit/static-html.js'; + +import type { Property } from '../../../core/view-manager/property.js'; +import type { TableSingleView } from '../table-view-manager.js'; + +export class DataViewColumnPreview extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = css` + affine-microsheet-data-view-column-preview { + pointer-events: none; + display: block; + } + `; + + private renderGroup(rows: string[]) { + const columnIndex = this.tableViewManager.propertyIndexGet(this.column.id); + return html` +
+ + ${repeat(rows, (id, index) => { + const height = this.table.querySelector( + `affine-microsheet-cell-container[data-row-id="${id}"]` + )?.clientHeight; + const style = styleMap({ + height: height + 'px', + }); + return html`
+
+ +
+
`; + })} +
+
+ `; + } + + override render() { + const groups = this.tableViewManager.groupManager.groupsDataList$.value; + if (!groups) { + const rows = this.tableViewManager.rows$.value; + return this.renderGroup(rows); + } + return groups.map(group => { + return html` +
+ ${this.renderGroup(group.rows)} + `; + }); + } + + @property({ attribute: false }) + accessor column!: Property; + + @property({ attribute: false }) + accessor table!: HTMLElement; + + @property({ attribute: false }) + accessor tableViewManager!: TableSingleView; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-microsheet-data-view-column-preview': DataViewColumnPreview; + } +} diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/header/microsheet-header-column.ts b/packages/affine/microsheet-data-view/src/view-presets/table/header/microsheet-header-column.ts new file mode 100644 index 000000000000..8b2be0901fad --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/table/header/microsheet-header-column.ts @@ -0,0 +1,629 @@ +import { + menu, + type MenuConfig, + popMenu, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { + insertPositionToIndex, + type InsertToPosition, +} from '@blocksuite/affine-shared/utils'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { + DeleteIcon, + DuplicateIcon, + InsertLeftIcon, + InsertRightIcon, + MoveLeftIcon, + MoveRightIcon, + ViewIcon, +} from '@blocksuite/icons/lit'; +import { css } from 'lit'; +import { property } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { createRef, ref } from 'lit/directives/ref.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { html } from 'lit/static-html.js'; + +import type { Property } from '../../../core/view-manager/property.js'; +import type { NumberPropertyDataType } from '../../../property-presets/index.js'; +import type { TableColumn, TableSingleView } from '../table-view-manager.js'; + +import { inputConfig, typeConfig } from '../../../core/common/property-menu.js'; +import { renderUniLit } from '../../../core/index.js'; +import { startDrag } from '../../../core/utils/drag.js'; +import { autoScrollOnBoundary } from '../../../core/utils/frame-loop.js'; +import { getResultInRange } from '../../../core/utils/utils.js'; +import { numberFormats } from '../../../property-presets/number/utils/formats.js'; +import { DEFAULT_COLUMN_TITLE_HEIGHT } from '../consts.js'; +import { getTableContainer } from '../types.js'; +import { DataViewColumnPreview } from './column-renderer.js'; +import { + getTableGroupRects, + getVerticalIndicator, + startDragWidthAdjustmentBar, +} from './vertical-indicator.js'; + +export class MicrosheetHeaderColumn extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = css` + affine-microsheet-header-column { + display: flex; + } + + .affine-microsheet-header-column-grabbing * { + cursor: grabbing; + } + `; + + private _clickColumn = () => { + if (this.tableViewManager.readonly$.value) { + return; + } + this.popMenu(); + }; + + private _clickTypeIcon = (event: MouseEvent) => { + if (this.tableViewManager.readonly$.value) { + return; + } + if (this.column.type$.value === 'title') { + return; + } + event.stopPropagation(); + popMenu(popupTargetFromElement(this), { + options: { + items: this.tableViewManager.propertyMetas.map(config => { + return menu.action({ + name: config.config.name, + isSelected: config.type === this.column.type$.value, + prefix: renderUniLit(this.tableViewManager.IconGet(config.type)), + select: () => { + this.column.typeSet?.(config.type); + }, + }); + }), + }, + }); + }; + + private _columnsOffset = (header: Element, _scale: number) => { + const columns = header.querySelectorAll('affine-microsheet-header-column'); + const left: ColumnOffset[] = []; + const right: ColumnOffset[] = []; + let curr = left; + const offsetArr: number[] = []; + const columnsArr = Array.from(columns); + for (let i = 0; i < columnsArr.length; i++) { + const v = columnsArr[i]; + if (v === this) { + curr = right; + offsetArr.push(-1); + continue; + } + curr.push({ + x: v.offsetLeft + v.offsetWidth / 2, + ele: v, + }); + offsetArr.push( + v.getBoundingClientRect().left - header.getBoundingClientRect().left + ); + if (i === columnsArr.length - 1) { + offsetArr.push( + v.getBoundingClientRect().right - header.getBoundingClientRect().left + ); + } + } + left.reverse(); + const getInsertPosition = (offset: number, width: number) => { + let result: InsertToPosition | undefined = undefined; + for (let i = 0; i < left.length; i++) { + const { x, ele } = left[i]; + if (x < offset) { + if (result) { + return result; + } + break; + } else { + result = { + before: true, + id: ele.column.id, + }; + } + } + const offsetRight = offset + width; + for (const { x, ele } of right) { + if (x > offsetRight) { + if (result) { + return result; + } + break; + } else { + result = { + before: false, + id: ele.column.id, + }; + } + } + return result; + }; + const fixedColumns = columnsArr.map(v => ({ id: v.column.id })); + const getInsertOffset = (insertPosition: InsertToPosition) => { + return offsetArr[insertPositionToIndex(insertPosition, fixedColumns)]; + }; + return { + computeInsertInfo: (offset: number, width: number) => { + const insertPosition = getInsertPosition(offset, width); + return { + insertPosition: insertPosition, + insertOffset: insertPosition + ? getInsertOffset(insertPosition) + : undefined, + }; + }, + }; + }; + + private _contextMenu = (e: MouseEvent) => { + if (this.tableViewManager.readonly$.value) { + return; + } + e.preventDefault(); + this.popMenu(e.currentTarget as HTMLElement); + }; + + private _enterWidthDragBar = () => { + if (this.tableViewManager.readonly$.value) { + return; + } + if (this.drawWidthDragBarTask) { + cancelAnimationFrame(this.drawWidthDragBarTask); + this.drawWidthDragBarTask = 0; + } + this.drawWidthDragBar(); + }; + + private _leaveWidthDragBar = () => { + cancelAnimationFrame(this.drawWidthDragBarTask); + this.drawWidthDragBarTask = 0; + getVerticalIndicator().remove(); + }; + + private drawWidthDragBar = () => { + const tableContainer = getTableContainer(this); + const tableRect = tableContainer.getBoundingClientRect(); + const rectList = getTableGroupRects(tableContainer); + getVerticalIndicator().display( + 0, + tableRect.top, + rectList, + this.getBoundingClientRect().right + ); + this.drawWidthDragBarTask = requestAnimationFrame(this.drawWidthDragBar); + }; + + private drawWidthDragBarTask = 0; + + private moveColumn = (evt: PointerEvent) => { + const tableContainer = getTableContainer(this); + const headerContainer = this.closest('affine-microsheet-column-header'); + const scrollContainer = tableContainer?.parentElement; + + if (!tableContainer || !headerContainer || !scrollContainer) return; + + const columnHeaderRect = this.getBoundingClientRect(); + const scale = columnHeaderRect.width / this.column.width$.value; + const headerContainerRect = tableContainer.getBoundingClientRect(); + + const rectOffsetLeft = evt.x - columnHeaderRect.left; + const offsetRight = columnHeaderRect.right - evt.x; + + const startOffset = + (columnHeaderRect.left - headerContainerRect.left) / scale; + const max = (headerContainerRect.width - columnHeaderRect.width) / scale; + + const { computeInsertInfo } = this._columnsOffset(headerContainer, scale); + const column = new DataViewColumnPreview(); + column.tableViewManager = this.tableViewManager; + column.column = this.column; + column.table = tableContainer; + const dragPreview = createDragPreview( + tableContainer, + columnHeaderRect.width / scale, + headerContainerRect.height / scale, + startOffset, + column + ); + const rectList = getTableGroupRects(tableContainer); + const dropPreview = getVerticalIndicator(); + const cancelScroll = autoScrollOnBoundary(scrollContainer, { + boundary: { + left: rectOffsetLeft, + right: offsetRight, + }, + onScroll: () => { + drag.move({ x: drag.last.x }); + }, + }); + const html = document.querySelector('html'); + html?.classList.toggle('affine-microsheet-header-column-grabbing', true); + const drag = startDrag<{ + insertPosition?: InsertToPosition; + }>(evt, { + onDrag: () => { + this.grabStatus = 'grabbing'; + return {}; + }, + onMove: ({ x }: { x: number }) => { + this.grabStatus = 'grabbing'; + const currentOffset = getResultInRange( + (x - tableContainer.getBoundingClientRect().left - rectOffsetLeft) / + scale, + 0, + max + ); + const insertInfo = computeInsertInfo( + currentOffset, + columnHeaderRect.width / scale + ); + if (insertInfo.insertOffset != null) { + dropPreview.display( + 0, + headerContainerRect.top, + rectList, + tableContainer.getBoundingClientRect().left + + insertInfo.insertOffset, + true + ); + } else { + dropPreview.remove(); + } + dragPreview.display(currentOffset); + return { + insertPosition: insertInfo.insertPosition, + }; + }, + onDrop: ({ insertPosition }) => { + this.grabStatus = 'grabEnd'; + if (insertPosition) { + this.tableViewManager.propertyMove(this.column.id, insertPosition); + } + }, + onClear: () => { + cancelScroll(); + html?.classList.toggle( + 'affine-microsheet-header-column-grabbing', + false + ); + dropPreview.remove(); + dragPreview.remove(); + }, + }); + }; + + private widthDragBar = createRef(); + + editTitle = () => { + this._clickColumn(); + }; + + private get readonly() { + return this.tableViewManager.readonly$.value; + } + + private popMenu(ele?: HTMLElement) { + const enableNumberFormatting = + this.tableViewManager.featureFlags$.value.enable_number_formatting; + + popMenu(popupTargetFromElement(ele ?? this), { + options: { + items: [ + inputConfig(this.column), + typeConfig(this.column), + // Number format begin + ...(enableNumberFormatting + ? [ + menu.subMenu({ + name: 'Number Format', + hide: () => + !this.column.dataUpdate || + this.column.type$.value !== 'number', + options: { + items: [ + numberFormatConfig(this.column), + ...numberFormats.map(format => { + const data = ( + this.column as Property< + number, + NumberPropertyDataType + > + ).data$.value; + return menu.action({ + isSelected: data.format === format.type, + prefix: html`${format.symbol}`, + name: format.label, + select: () => { + if (data.format === format.type) return; + this.column.dataUpdate(() => ({ + format: format.type, + })); + }, + }); + }), + ], + }, + }), + ] + : []), + // Number format end + menu.group({ + items: [ + menu.action({ + name: 'Hide In View', + prefix: ViewIcon(), + hide: () => + this.column.hide$.value || + this.column.type$.value === 'title', + select: () => { + this.column.hideSet(true); + }, + }), + ], + }), + menu.group({ + items: [ + menu.action({ + name: 'Insert Left Column', + prefix: InsertLeftIcon(), + select: () => { + this.tableViewManager.propertyAdd({ + id: this.column.id, + before: true, + }); + Promise.resolve() + .then(() => { + const pre = this.previousElementSibling; + if (pre instanceof MicrosheetHeaderColumn) { + pre.editTitle(); + pre.scrollIntoView({ + inline: 'nearest', + block: 'nearest', + }); + } + }) + .catch(console.error); + }, + }), + menu.action({ + name: 'Insert Right Column', + prefix: InsertRightIcon(), + select: () => { + this.tableViewManager.propertyAdd({ + id: this.column.id, + before: false, + }); + Promise.resolve() + .then(() => { + const next = this.nextElementSibling; + if (next instanceof MicrosheetHeaderColumn) { + next.editTitle(); + next.scrollIntoView({ + inline: 'nearest', + block: 'nearest', + }); + } + }) + .catch(console.error); + }, + }), + menu.action({ + name: 'Move Left', + prefix: MoveLeftIcon(), + hide: () => this.column.isFirst, + select: () => { + const preId = this.tableViewManager.propertyPreGet( + this.column.id + )?.id; + if (!preId) { + return; + } + this.tableViewManager.propertyMove(this.column.id, { + id: preId, + before: true, + }); + }, + }), + menu.action({ + name: 'Move Right', + prefix: MoveRightIcon(), + hide: () => this.column.isLast, + select: () => { + const nextId = this.tableViewManager.propertyNextGet( + this.column.id + )?.id; + if (!nextId) { + return; + } + this.tableViewManager.propertyMove(this.column.id, { + id: nextId, + before: false, + }); + }, + }), + ], + }), + menu.group({ + items: [ + menu.action({ + name: 'Duplicate', + prefix: DuplicateIcon(), + hide: () => + !this.column.duplicate || this.column.type$.value === 'title', + select: () => { + this.column.duplicate?.(); + }, + }), + menu.action({ + name: 'Delete', + prefix: DeleteIcon(), + hide: () => + !this.column.delete || this.column.type$.value === 'title', + select: () => { + this.column.delete?.(); + }, + class: 'delete-item', + }), + ], + }), + ], + }, + }); + } + + private widthDragStart(event: PointerEvent) { + startDragWidthAdjustmentBar( + event, + getTableContainer(this), + this.getBoundingClientRect().width, + this.column + ); + } + + override connectedCallback() { + super.connectedCallback(); + const table = this.closest('affine-microsheet-table'); + if (table) { + this.disposables.add( + table.props.handleEvent('dragStart', context => { + if (this.tableViewManager.readonly$.value) { + return; + } + const event = context.get('pointerState').raw; + const target = event.target; + if (target instanceof Element) { + if (this.widthDragBar.value?.contains(target)) { + event.preventDefault(); + this.widthDragStart(event); + return true; + } + if (this.contains(target)) { + event.preventDefault(); + this.moveColumn(event); + return true; + } + } + return false; + }) + ); + } + } + + override render() { + const column = this.column; + const style = styleMap({ + height: DEFAULT_COLUMN_TITLE_HEIGHT + 'px', + }); + const classes = classMap({ + 'affine-microsheet-column-move': true, + [this.grabStatus]: true, + }); + return html` +
+ ${this.readonly + ? null + : html` `} +
+
+ +
+
+
+ ${column.name$.value} +
+
+
+
+
+
+
+ `; + } + + @property({ attribute: false }) + accessor column!: TableColumn; + + @property({ attribute: false }) + accessor grabStatus: 'grabStart' | 'grabEnd' | 'grabbing' = 'grabEnd'; + + @property({ attribute: false }) + accessor tableViewManager!: TableSingleView; +} + +type ColumnOffset = { + x: number; + ele: MicrosheetHeaderColumn; +}; + +const createDragPreview = ( + container: Element, + width: number, + height: number, + startLeft: number, + content: HTMLElement +) => { + const div = document.createElement('div'); + div.append(content); + // div.style.pointerEvents='none'; + div.style.opacity = '0.8'; + div.style.position = 'absolute'; + div.style.width = `${width}px`; + div.style.height = `${height}px`; + div.style.left = `${startLeft}px`; + div.style.top = `0px`; + div.style.zIndex = '9'; + container.append(div); + return { + display(offset: number) { + div.style.left = `${Math.round(offset)}px`; + }, + remove() { + div.remove(); + }, + }; +}; + +function numberFormatConfig(column: Property): MenuConfig { + return () => + html` `; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-microsheet-header-column': MicrosheetHeaderColumn; + } +} diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/header/number-format-bar.ts b/packages/affine/microsheet-data-view/src/view-presets/table/header/number-format-bar.ts new file mode 100644 index 000000000000..baf8bfbcc228 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/table/header/number-format-bar.ts @@ -0,0 +1,145 @@ +import { WithDisposable } from '@blocksuite/global/utils'; +import { css, html, LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; + +import type { Property } from '../../../core/view-manager/property.js'; + +import { formatNumber } from '../../../property-presets/number/utils/formatter.js'; + +const IncreaseDecimalPlacesIcon = html` + + + +`; + +const DecreaseDecimalPlacesIcon = html` + + + +`; + +export class MicrosheetNumberFormatBar extends WithDisposable(LitElement) { + static override styles = css` + .number-format-toolbar-container { + padding: 4px 12px; + display: flex; + gap: 7px; + flex-direction: column; + } + + .number-format-decimal-places { + display: flex; + gap: 4px; + align-items: center; + justify-content: flex-start; + } + + .number-format-toolbar-button { + box-sizing: border-box; + background-color: transparent; + border: none; + border-radius: 4px; + color: var(--affine-icon-color); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 4px; + position: relative; + + user-select: none; + } + + .number-format-toolbar-button svg { + width: 16px; + height: 16px; + } + + .number-formatting-sample { + font-size: var(--affine-font-xs); + color: var(--affine-icon-color); + margin-left: auto; + } + .number-format-toolbar-button:hover { + background-color: var(--affine-hover-color); + } + .divider { + width: 100%; + height: 1px; + background-color: var(--affine-border-color); + } + `; + + private _decrementDecimalPlaces = () => { + this.column.dataUpdate(data => ({ + decimal: Math.max(((data.decimal as number) ?? 0) - 1, 0), + })); + this.requestUpdate(); + }; + + private _incrementDecimalPlaces = () => { + this.column.dataUpdate(data => ({ + decimal: Math.min(((data.decimal as number) ?? 0) + 1, 8), + })); + this.requestUpdate(); + }; + + override render() { + return html` +
+
+ + + + + ( ${formatNumber( + 1, + 'number', + (this.column.data$.value.decimal as number) ?? 0 + )} ) + +
+
+
+ `; + } + + @property({ attribute: false }) + accessor column!: Property; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-microsheet-number-format-bar': MicrosheetNumberFormatBar; + } +} diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/header/styles.ts b/packages/affine/microsheet-data-view/src/view-presets/table/header/styles.ts new file mode 100644 index 000000000000..8c6ad2a21b87 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/table/header/styles.ts @@ -0,0 +1,354 @@ +import { baseTheme } from '@toeverything/theme'; +import { css, unsafeCSS } from 'lit'; + +import { + DEFAULT_ADD_BUTTON_WIDTH, + DEFAULT_COLUMN_MIN_WIDTH, + DEFAULT_COLUMN_TITLE_HEIGHT, +} from '../consts.js'; + +export const styles = css` + affine-microsheet-column-header { + display: block; + background-color: var(--affine-background-primary-color); + position: relative; + z-index: 2; + } + + .affine-microsheet-column-header { + position: relative; + display: flex; + flex-direction: row; + border-bottom: 1px solid var(--affine-border-color); + border-top: 1px solid var(--affine-border-color); + box-sizing: border-box; + user-select: none; + background-color: var(--affine-background-primary-color); + } + + .affine-microsheet-column { + cursor: pointer; + } + + .microsheet-cell { + min-width: ${DEFAULT_COLUMN_MIN_WIDTH}px; + user-select: none; + } + + .microsheet-cell.add-column-button { + flex: 1; + min-width: ${DEFAULT_ADD_BUTTON_WIDTH}px; + min-height: 100%; + display: flex; + align-items: center; + } + + .affine-microsheet-column-content { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + height: 100%; + padding: 8px; + box-sizing: border-box; + position: relative; + } + + .affine-microsheet-column-content:hover, + .affine-microsheet-column-content.edit { + background: var(--affine-hover-color); + } + + .affine-microsheet-column-content.edit .affine-microsheet-column-text-icon { + opacity: 1; + } + + .affine-microsheet-column-text { + flex: 1; + display: flex; + align-items: center; + gap: 6px; + /* https://stackoverflow.com/a/36247448/15443637 */ + overflow: hidden; + color: var(--affine-text-secondary-color); + font-size: 14px; + position: relative; + } + + .affine-microsheet-column-type-icon { + display: flex; + align-items: center; + border-radius: 4px; + padding: 2px; + } + + .affine-microsheet-column-type-icon svg { + width: 16px; + height: 16px; + fill: var(--affine-icon-color); + } + + .affine-microsheet-column-text-content { + flex: 1; + display: flex; + align-items: center; + overflow: hidden; + } + + .affine-microsheet-column-content:hover .affine-microsheet-column-text-icon { + opacity: 1; + } + + .affine-microsheet-column-text-input { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .affine-microsheet-column-text-icon { + display: flex; + align-items: center; + width: 16px; + height: 16px; + background: var(--affine-white); + border: 1px solid var(--affine-border-color); + border-radius: 4px; + opacity: 0; + } + + .affine-microsheet-column-text-save-icon { + display: flex; + align-items: center; + width: 16px; + height: 16px; + border: 1px solid transparent; + border-radius: 4px; + fill: var(--affine-icon-color); + } + + .affine-microsheet-column-text-save-icon:hover { + background: var(--affine-white); + border-color: var(--affine-border-color); + } + + .affine-microsheet-column-text-icon svg { + fill: var(--affine-icon-color); + } + + .affine-microsheet-column-input { + width: 100%; + height: 24px; + padding: 0; + border: none; + color: inherit; + font-weight: 600; + font-size: 14px; + font-family: ${unsafeCSS(baseTheme.fontSansFamily)}; + background: transparent; + } + + .affine-microsheet-column-input:focus { + outline: none; + } + + .affine-microsheet-column-move { + display: flex; + align-items: center; + } + + .affine-microsheet-column-move svg { + width: 10px; + height: 14px; + color: var(--affine-black-10); + cursor: grab; + opacity: 0; + } + + .affine-microsheet-column-content:hover svg { + opacity: 1; + } + + .affine-microsheet-add-column-button { + position: sticky; + right: 0; + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 38px; + cursor: pointer; + } + + .header-add-column-button { + height: ${DEFAULT_COLUMN_TITLE_HEIGHT}px; + background-color: var(--affine-background-primary-color); + display: flex; + align-items: center; + justify-content: center; + width: 40px; + cursor: pointer; + } + + @media print { + .header-add-column-button { + display: none; + } + } + + .header-add-column-button svg { + color: var(--affine-icon-color); + } + + .affine-microsheet-column-type-menu-icon { + border: 1px solid var(--affine-border-color); + border-radius: 4px; + padding: 5px; + background-color: var(--affine-background-secondary-color); + } + + .affine-microsheet-column-type-menu-icon svg { + color: var(--affine-text-secondary-color); + width: 20px; + height: 20px; + + } + + .affine-microsheet-column-move-preview { + position: fixed; + z-index: 100; + width: 100px; + height: 100px; + background: var(--affine-text-emphasis-color); + } + + .affine-microsheet-column-move { + --color: var(--affine-placeholder-color); + --active: var(--affine-black-10); + --bw: 1px; + --bw2: -1px; + cursor: grab; + background: none; + border: none; + border-radius: 0; + position: absolute; + inset: 0; + } + + .affine-microsheet-column-move .control-l::before, + .affine-microsheet-column-move .control-h::before, + .affine-microsheet-column-move .control-l::after, + .affine-microsheet-column-move .control-h::after, + .affine-microsheet-column-move .control-r, + .affine-microsheet-column-move .hover-trigger { + --delay: 0s; + --delay-opacity: 0s; + content: ''; + position: absolute; + transition: all 0.2s ease var(--delay), + opacity 0.2s ease var(--delay-opacity); + } + + .affine-microsheet-column-move .control-r { + --delay: 0s; + --delay-opacity: 0.6s; + width: 4px; + border-radius: 1px; + height: 32%; + background: var(--color); + right: 6px; + top: 50%; + transform: translateY(-50%); + opacity: 0; + pointer-events: none; + } + + .affine-microsheet-column-move .hover-trigger { + width: 12px; + height: 80%; + right: 3px; + top: 10%; + background: transparent + z-index: 1; + opacity: 1; + } + + .affine-microsheet-column-move:hover .control-r { + opacity: 1; + } + + .affine-microsheet-column-move .control-h::before, + .affine-microsheet-column-move .control-h::after { + --delay: 0.2s; + width: calc(100% - var(--bw2) * 2); + opacity: 0; + height: var(--bw); + right: var(--bw2); + background: var(--active); + } + + .affine-microsheet-column-move .control-h::before { + top: var(--bw2); + } + + .affine-microsheet-column-move .control-h::after { + bottom: var(--bw2); + } + + .affine-microsheet-column-move .control-l::before { + --delay: 0s; + width: var(--bw); + height: 100%; + opacity: 0; + background: var(--active); + left: var(--bw2); + } + + .affine-microsheet-column-move .control-l::before { + top: 0; + } + + .affine-microsheet-column-move .control-l::after { + bottom: 0; + } + + /* handle--active style */ + + .affine-microsheet-column-move:hover .control-r { + --delay-opacity: 0s; + opacity: 1; + } + + .affine-microsheet-column-move:active .control-r, + .hover-trigger:hover ~ .control-r, + .grabbing.affine-microsheet-column-move .control-r { + opacity: 1; + --delay: 0s; + --delay-opacity: 0s; + right: var(--bw2); + width: var(--bw); + height: 100%; + background: var(--active); + } + + .affine-microsheet-column-move:active .control-h::before, + .affine-microsheet-column-move:active .control-h::after, + .hover-trigger:hover ~ .control-h::before, + .hover-trigger:hover ~ .control-h::after, + .grabbing.affine-microsheet-column-move .control-h::before, + .grabbing.affine-microsheet-column-move .control-h::after { + --delay: 0.2s; + width: calc(100% - var(--bw2) * 2); + opacity: 1; + } + + .affine-microsheet-column-move:active .control-l::before, + .affine-microsheet-column-move:active .control-l::after, + .hover-trigger:hover ~ .control-l::before, + .hover-trigger:hover ~ .control-l::after, + .grabbing.affine-microsheet-column-move .control-l::before, + .grabbing.affine-microsheet-column-move .control-l::after { + --delay: 0.4s; + opacity: 1; + } +`; diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/header/vertical-indicator.ts b/packages/affine/microsheet-data-view/src/view-presets/table/header/vertical-indicator.ts new file mode 100644 index 000000000000..f23976faf647 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/table/header/vertical-indicator.ts @@ -0,0 +1,191 @@ +import { ShadowlessElement } from '@blocksuite/block-std'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { css, html } from 'lit'; +import { property } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import type { TableColumn } from '../table-view-manager.js'; + +import { startDrag } from '../../../core/utils/drag.js'; +import { getResultInRange } from '../../../core/utils/utils.js'; +import { DEFAULT_COLUMN_MIN_WIDTH } from '../consts.js'; + +type GroupRectList = { + top: number; + bottom: number; +}[]; + +export class TableVerticalIndicator extends WithDisposable(ShadowlessElement) { + static override styles = css` + data-view-table-vertical-indicator { + position: fixed; + left: 0; + top: 0; + z-index: 1; + pointer-events: none; + } + + .vertical-indicator-container { + position: absolute; + pointer-events: none; + } + + .vertical-indicator-group { + position: absolute; + z-index: 1; + width: 100%; + background-color: var(--affine-hover-color); + pointer-events: none; + } + .vertical-indicator-group::after { + position: absolute; + z-index: 1; + width: 2px; + height: 100%; + content: ''; + right: 0; + background-color: var(--affine-primary-color); + border-radius: 1px; + } + .with-shadow.vertical-indicator-group::after { + box-shadow: 0px 0px 8px 0px rgba(30, 150, 235, 0.35); + } + `; + + protected override render(): unknown { + const containerStyle = styleMap({ + top: `${this.top}px`, + left: `${this.left}px`, + width: `${Math.max(this.width, 1)}px`, + }); + return html` +
+ ${repeat(this.lines, ({ top, bottom }) => { + const groupStyle = styleMap({ + top: `${top}px`, + height: `${bottom - top}px`, + }); + const groupClass = classMap({ + 'with-shadow': this.shadow, + 'vertical-indicator-group': true, + }); + return html`
`; + })} +
+ `; + } + + @property({ attribute: false }) + accessor left!: number; + + @property({ attribute: false }) + accessor lines!: GroupRectList; + + @property({ attribute: false }) + accessor shadow = false; + + @property({ attribute: false }) + accessor top!: number; + + @property({ attribute: false }) + accessor width!: number; +} + +export const getTableGroupRects = (tableContainer: HTMLElement) => { + const tableRect = tableContainer.getBoundingClientRect(); + const groups = tableContainer.querySelectorAll( + 'affine-microsheet-data-view-table-group' + ); + return Array.from(groups).map(group => { + const groupRect = group.getBoundingClientRect(); + const top = + group + .querySelector('.affine-microsheet-column-header') + ?.getBoundingClientRect().top ?? groupRect.top; + const bottom = + group + .querySelector('.affine-microsheet-block-rows') + ?.getBoundingClientRect().bottom ?? groupRect.bottom; + return { + top: top - tableRect.top, + bottom: bottom - tableRect.top, + }; + }); +}; +export const startDragWidthAdjustmentBar = ( + evt: PointerEvent, + tableContainer: HTMLElement, + width: number, + column: TableColumn +) => { + const scale = width / column.width$.value; + const tableRect = tableContainer.getBoundingClientRect(); + const left = + tableContainer + .querySelector( + `affine-microsheet-header-column[data-column-id='${column.id}']` + ) + ?.getBoundingClientRect().left ?? 0; + const rectList = getTableGroupRects(tableContainer); + const preview = getVerticalIndicator(); + preview.display(column.width$.value * scale, tableRect.top, rectList, left); + tableContainer.style.pointerEvents = 'none'; + startDrag<{ width: number }>(evt, { + onDrag: () => ({ width: column.width$.value }), + onMove: ({ x }) => { + const width = Math.round( + getResultInRange((x - left) / scale, DEFAULT_COLUMN_MIN_WIDTH, Infinity) + ); + preview.display(width * scale, tableRect.top, rectList, left); + return { + width, + }; + }, + onDrop: ({ width }) => { + column.updateWidth(width); + }, + onClear: () => { + tableContainer.style.pointerEvents = 'auto'; + preview.remove(); + }, + }); +}; +let preview: VerticalIndicator | null = null; +type VerticalIndicator = { + display: ( + width: number, + top: number, + lines: GroupRectList, + left: number, + shadow?: boolean + ) => void; + remove: () => void; +}; +export const getVerticalIndicator = (): VerticalIndicator => { + if (!preview) { + const dragBar = new TableVerticalIndicator(); + preview = { + display( + width: number, + top: number, + lines: GroupRectList, + left: number, + shadow = false + ) { + document.body.append(dragBar); + dragBar.left = left; + dragBar.lines = lines; + dragBar.top = top; + dragBar.width = width; + dragBar.shadow = shadow; + }, + remove() { + dragBar.remove(); + }, + }; + } + + return preview; +}; diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/index.ts b/packages/affine/microsheet-data-view/src/view-presets/table/index.ts new file mode 100644 index 000000000000..2ef547ad3b2d --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/table/index.ts @@ -0,0 +1,4 @@ +export * from './define.js'; +export * from './renderer.js'; +export * from './table-view.js'; +export * from './table-view-manager.js'; diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/renderer.ts b/packages/affine/microsheet-data-view/src/view-presets/table/renderer.ts new file mode 100644 index 000000000000..142c080c652f --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/table/renderer.ts @@ -0,0 +1,9 @@ +import { createUniComponentFromWebComponent } from '../../core/utils/uni-component/uni-component.js'; +import { createIcon } from '../../core/utils/uni-icon.js'; +import { tableViewModel } from './define.js'; +import { DataViewTable } from './table-view.js'; + +export const tableViewMeta = tableViewModel.createMeta({ + view: createUniComponentFromWebComponent(DataViewTable), + icon: createIcon('DatabaseTableViewIcon'), +}); diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/row/row-select-checkbox.ts b/packages/affine/microsheet-data-view/src/view-presets/table/row/row-select-checkbox.ts new file mode 100644 index 000000000000..35305a5d3acf --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/table/row/row-select-checkbox.ts @@ -0,0 +1,82 @@ +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { CheckBoxCkeckSolidIcon, CheckBoxUnIcon } from '@blocksuite/icons/lit'; +import { computed, type ReadonlySignal } from '@preact/signals-core'; +import { css, html } from 'lit'; +import { property } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; + +import { + TableRowSelection, + type TableViewSelectionWithType, +} from '../types.js'; + +export class RowSelectCheckbox extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = css` + microsheet-row-select-checkbox { + display: contents; + } + .microsheet-row-select-checkbox { + display: flex; + align-items: center; + background-color: var(--affine-background-primary-color); + opacity: 0; + cursor: pointer; + font-size: 20px; + color: var(--affine-icon-color); + } + .microsheet-row-select-checkbox:hover { + opacity: 1; + } + .microsheet-row-select-checkbox.selected { + opacity: 1; + } + `; + + @property({ attribute: false }) + accessor groupKey: string | undefined; + + @property({ attribute: false }) + accessor rowId!: string; + + @property({ attribute: false }) + accessor selection!: ReadonlySignal; + + isSelected$ = computed(() => { + const selection = this.selection.value; + if (!selection || selection.selectionType !== 'row') { + return false; + } + return TableRowSelection.includes(selection, { + id: this.rowId, + groupKey: this.groupKey, + }); + }); + + override connectedCallback() { + super.connectedCallback(); + this.disposables.addFromEvent(this, 'click', () => { + this.closest('affine-microsheet-table')?.selectionController.toggleRow( + this.rowId, + this.groupKey + ); + }); + } + + override render() { + const classString = classMap({ + 'row-selected-bg': true, + 'microsheet-row-select-checkbox': true, + selected: this.isSelected$.value, + }); + return html` +
+ ${this.isSelected$.value + ? CheckBoxCkeckSolidIcon({ style: `color:#1E96EB` }) + : CheckBoxUnIcon()} +
+ `; + } +} diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/row/row.ts b/packages/affine/microsheet-data-view/src/view-presets/table/row/row.ts new file mode 100644 index 000000000000..8533c72aacf0 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/table/row/row.ts @@ -0,0 +1,265 @@ +import { popupTargetFromElement } from '@blocksuite/affine-components/context-menu'; +import { type BlockStdScope, ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { CenterPeekIcon, MoreHorizontalIcon } from '@blocksuite/icons/lit'; +import { css, nothing } from 'lit'; +import { property } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { html } from 'lit/static-html.js'; + +import type { DataViewRenderer } from '../../../core/data-view.js'; +import type { TableSingleView } from '../table-view-manager.js'; + +import { openDetail, popRowMenu } from '../components/menu.js'; +import { DEFAULT_COLUMN_MIN_WIDTH } from '../consts.js'; +import { TableRowSelection, type TableViewSelection } from '../types.js'; + +export class TableRow extends SignalWatcher(WithDisposable(ShadowlessElement)) { + static override styles = css` + .affine-microsheet-block-row:has(.microsheet-row-select-checkbox.selected) { + background: var(--affine-primary-color-04); + } + .affine-microsheet-block-row:has(.microsheet-row-select-checkbox.selected) + .row-selected-bg { + position: relative; + } + .affine-microsheet-block-row:has(.microsheet-row-select-checkbox.selected) + .row-selected-bg:before { + content: ''; + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + background: var(--affine-primary-color-04); + } + .affine-microsheet-block-row { + width: 100%; + display: flex; + flex-direction: row; + border-bottom: 1px solid var(--affine-border-color); + position: relative; + } + + .affine-microsheet-block-row.selected > .microsheet-cell { + background: transparent; + } + + .microsheet-cell { + min-width: ${DEFAULT_COLUMN_MIN_WIDTH}px; + } + + .row-ops { + position: relative; + width: 0; + margin-top: 8px; + height: max-content; + visibility: hidden; + display: flex; + gap: 4px; + cursor: pointer; + justify-content: end; + } + + .row-op:last-child { + margin-right: 8px; + } + + .affine-microsheet-block-row .show-on-hover-row { + visibility: hidden; + opacity: 0; + } + .affine-microsheet-block-row:hover .show-on-hover-row { + visibility: visible; + opacity: 1; + } + + .row-op { + display: flex; + padding: 4px; + border-radius: 4px; + box-shadow: 0px 0px 4px 0px rgba(66, 65, 73, 0.14); + background-color: var(--affine-background-primary-color); + position: relative; + } + + .row-op:hover:before { + content: ''; + border-radius: 4px; + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + background-color: var(--affine-hover-color); + } + + .row-op svg { + fill: var(--affine-icon-color); + color: var(--affine-icon-color); + width: 16px; + height: 16px; + } + .data-view-table-view-drag-handler { + width: 4px; + height: 38px; + display: flex; + align-items: center; + justify-content: center; + cursor: grab; + background-color: var(--affine-background-primary-color); + } + `; + + private _clickDragHandler = () => { + if (this.view.readonly$.value) { + return; + } + this.selectionController?.toggleRow(this.rowId, this.groupKey); + }; + + contextMenu = (e: MouseEvent) => { + if (this.view.readonly$.value) { + return; + } + const selection = this.selectionController; + if (!selection) { + return; + } + e.preventDefault(); + const ele = e.target as HTMLElement; + const cell = ele.closest('affine-microsheet-cell-container'); + const row = { id: this.rowId, groupKey: this.groupKey }; + if (!TableRowSelection.includes(selection.selection, row)) { + selection.selection = TableRowSelection.create({ + rows: [row], + }); + } + const target = + cell ?? + (e.target as HTMLElement).closest('.microsheet-cell') ?? // for last add btn cell + (e.target as HTMLElement); + + popRowMenu(this.dataViewEle, popupTargetFromElement(target), selection); + }; + + setSelection = (selection?: TableViewSelection) => { + if (this.selectionController) { + this.selectionController.selection = selection; + } + }; + + get groupKey() { + return this.closest('affine-microsheet-data-view-table-group')?.group?.key; + } + + get selectionController() { + return this.closest('affine-microsheet-table')?.selectionController; + } + + override connectedCallback() { + super.connectedCallback(); + this.disposables.addFromEvent(this, 'contextmenu', this.contextMenu); + + this.classList.add('affine-microsheet-block-row', 'microsheet-row'); + } + + protected override render(): unknown { + const view = this.view; + return html` + ${repeat( + view.properties$.value, + v => v.id, + (column, i) => { + const clickDetail = () => { + if (!this.selectionController) { + return; + } + this.setSelection( + TableRowSelection.create({ + rows: [{ id: this.rowId, groupKey: this.groupKey }], + }) + ); + openDetail(this.dataViewEle, this.rowId, this.selectionController); + }; + const openMenu = (e: MouseEvent) => { + if (!this.selectionController) { + return; + } + const ele = e.currentTarget as HTMLElement; + const row = { id: this.rowId, groupKey: this.groupKey }; + this.setSelection( + TableRowSelection.create({ + rows: [row], + }) + ); + popRowMenu( + this.dataViewEle, + popupTargetFromElement(ele), + this.selectionController + ); + }; + return html` +
+ + +
+ ${!column.readonly$.value && + column.view.mainProperties$.value.titleColumn === column.id + ? html`
+
+ ${CenterPeekIcon()} +
+ ${!view.readonly$.value + ? html`
+ ${MoreHorizontalIcon()} +
` + : nothing} +
` + : nothing} + `; + } + )} +
+ `; + } + + @property({ attribute: false }) + accessor dataViewEle!: DataViewRenderer; + + @property({ attribute: false }) + accessor rowId!: string; + + @property({ attribute: false }) + accessor rowIndex!: number; + + @property({ attribute: false }) + accessor std!: BlockStdScope; + + @property({ attribute: false }) + accessor view!: TableSingleView; +} + +declare global { + interface HTMLElementTagNameMap { + 'data-view-table-row': TableRow; + } +} diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/stats/column-stats-bar.ts b/packages/affine/microsheet-data-view/src/view-presets/table/stats/column-stats-bar.ts new file mode 100644 index 000000000000..fcea58853b4c --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/table/stats/column-stats-bar.ts @@ -0,0 +1,56 @@ +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { css, html } from 'lit'; +import { property } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import type { GroupData } from '../../../core/common/group-by/helper.js'; +import type { TableSingleView } from '../table-view-manager.js'; + +import { LEFT_TOOL_BAR_WIDTH, STATS_BAR_HEIGHT } from '../consts.js'; + +const styles = css` + .affine-microsheet-column-stats { + width: 100%; + margin-left: ${LEFT_TOOL_BAR_WIDTH}px; + height: ${STATS_BAR_HEIGHT}px; + display: flex; + } +`; + +export class MicrosheetColumnStats extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = styles; + + protected override render() { + const cols = this.view.properties$.value; + + return html` +
+ ${repeat( + cols, + col => col.id, + col => { + return html``; + } + )} +
+ `; + } + + @property({ attribute: false }) + accessor group: GroupData | undefined = undefined; + + @property({ attribute: false }) + accessor view!: TableSingleView; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-microsheet-column-stats': MicrosheetColumnStats; + } +} diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/stats/column-stats-column.ts b/packages/affine/microsheet-data-view/src/view-presets/table/stats/column-stats-column.ts new file mode 100644 index 000000000000..1efb120f3218 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/table/stats/column-stats-column.ts @@ -0,0 +1,229 @@ +import { + menu, + type MenuConfig, + popFilterableSimpleMenu, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { ArrowDownSmallIcon } from '@blocksuite/icons/lit'; +import { Text } from '@blocksuite/store'; +import { computed, signal } from '@preact/signals-core'; +import { css, html } from 'lit'; +import { property } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import type { GroupData } from '../../../core/common/group-by/helper.js'; +import type { StatsFunction } from '../../../core/common/stats/type.js'; +import type { TableColumn } from '../table-view-manager.js'; + +import { statsFunctions } from '../../../core/common/stats/index.js'; +import { typesystem } from '../../../core/logical/typesystem.js'; +import { DEFAULT_COLUMN_MIN_WIDTH } from '../consts.js'; + +const styles = css` + .stats-cell { + cursor: pointer; + transition: opacity 230ms ease; + font-size: 12px; + color: var(--affine-text-secondary-color); + display: flex; + opacity: 0; + min-width: ${DEFAULT_COLUMN_MIN_WIDTH}px; + justify-content: flex-end; + height: 100%; + align-items: center; + } + + .affine-microsheet-column-stats:hover .stats-cell { + opacity: 1; + } + + .stats-cell:hover { + background-color: var(--affine-hover-color); + cursor: pointer; + } + + .stats-cell[calculated='true'] { + opacity: 1; + } + + .stats-cell .content { + display: flex; + align-items: center; + justify-content: center; + gap: 0.2rem; + margin-inline: 5px; + } + + .label { + text-transform: uppercase; + color: var(--affine-text-secondary-color); + } + + .value { + color: var(--affine-text-primary-color); + } +`; + +export class MicrosheetColumnStatsCell extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = styles; + + @property({ attribute: false }) + accessor column!: TableColumn; + + cellValues$ = computed(() => { + if (this.group) { + return this.group.rows.map(id => { + return this.column.valueGet(id); + }); + } + return this.column.cells$.value.map(cell => cell.value$.value); + }); + + groups$ = computed(() => { + const groups: Record> = {}; + + statsFunctions.forEach(func => { + if (!typesystem.isSubtype(func.dataType, this.column.dataType$.value)) { + return; + } + if (!groups[func.group]) { + groups[func.group] = {}; + } + const oldFunc = groups[func.group][func.type]; + if (!oldFunc || typesystem.isSubtype(oldFunc.dataType, func.dataType)) { + if (!func.impl) { + delete groups[func.group][func.type]; + } else { + groups[func.group][func.type] = func; + } + } + }); + return groups; + }); + + openMenu = (ev: MouseEvent) => { + const menus: MenuConfig[] = Object.entries(this.groups$.value).map( + ([group, funcs]) => { + return menu.subMenu({ + name: group, + options: { + items: Object.values(funcs).map(func => { + return menu.action({ + isSelected: func.type === this.column.statCalcOp$.value, + name: func.menuName ?? func.type, + select: () => { + this.column.updateStatCalcOp(func.type); + }, + }); + }), + }, + }); + } + ); + popFilterableSimpleMenu(popupTargetFromElement(ev.target as HTMLElement), [ + menu.action({ + isSelected: !this.column.statCalcOp$.value, + name: 'None', + select: () => { + this.column.updateStatCalcOp(); + }, + }), + ...menus, + ]); + }; + + statsFunc$ = computed(() => { + return Object.values(this.groups$.value) + .flatMap(group => Object.values(group)) + .find(func => func.type === this.column.statCalcOp$.value); + }); + + values$ = signal([]); + + statsResult$ = computed(() => { + const meta = this.column.view.propertyMetaGet(this.column.type$.value); + if (!meta) { + return null; + } + const func = this.statsFunc$.value; + if (!func) { + return null; + } + return { + name: func.displayName, + value: func.impl?.(this.values$.value, { meta }) ?? '', + }; + }); + + subscriptionMap = new Map void>(); + + override connectedCallback(): void { + super.connectedCallback(); + this.disposables.addFromEvent(this, 'click', this.openMenu); + this.disposables.add( + this.cellValues$.subscribe(values => { + const map = new Map void>(); + values.forEach(value => { + if (value instanceof Text) { + const unsub = this.subscriptionMap.get(value); + if (unsub) { + map.set(value, unsub); + this.subscriptionMap.delete(value); + } else { + const f = () => { + this.values$.value = [...this.cellValues$.value]; + }; + value.yText.observe(f); + map.set(value, () => { + value.yText.unobserve(f); + }); + } + } + }); + this.subscriptionMap.forEach(unsub => { + unsub(); + }); + this.subscriptionMap = map; + this.values$.value = this.cellValues$.value; + }) + ); + this.disposables.add(() => { + this.subscriptionMap.forEach(unsub => { + unsub(); + }); + }); + } + + protected override render() { + const style = { + width: `${this.column.width$.value}px`, + }; + return html`
+
+ ${!this.statsResult$.value + ? html`Calculate ${ArrowDownSmallIcon()}` + : html` + ${this.statsResult$.value.name} + ${this.statsResult$.value.value} + `} +
+
`; + } + + @property({ attribute: false }) + accessor group: GroupData | undefined = undefined; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-microsheet-column-stats-cell': MicrosheetColumnStatsCell; + } +} diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/table-view-manager.ts b/packages/affine/microsheet-data-view/src/view-presets/table/table-view-manager.ts new file mode 100644 index 000000000000..4f5fa0af6cfb --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/table/table-view-manager.ts @@ -0,0 +1,353 @@ +import { + insertPositionToIndex, + type InsertToPosition, +} from '@blocksuite/affine-shared/utils'; +import { computed, type ReadonlySignal } from '@preact/signals-core'; + +import type { ViewManager } from '../../core/view-manager/view-manager.js'; +import type { TableViewData } from './define.js'; +import type { StatCalcOpType } from './types.js'; + +import { emptyFilterGroup, type FilterGroup } from '../../core/common/ast.js'; +import { defaultGroupBy } from '../../core/common/group-by.js'; +import { + GroupManager, + sortByManually, +} from '../../core/common/group-by/helper.js'; +import { evalFilter } from '../../core/logical/eval-filter.js'; +import { PropertyBase } from '../../core/view-manager/property.js'; +import { + type SingleView, + SingleViewBase, +} from '../../core/view-manager/single-view.js'; +import { DEFAULT_COLUMN_WIDTH } from './consts.js'; + +export class TableSingleView extends SingleViewBase { + propertiesWithoutFilter$ = computed(() => { + const needShow = new Set(this.dataSource.properties$.value); + const result: string[] = []; + this.data$.value?.columns.forEach(v => { + if (needShow.has(v.id)) { + result.push(v.id); + needShow.delete(v.id); + } + }); + result.push(...needShow); + return result; + }); + + private computedColumns$ = computed(() => { + return this.propertiesWithoutFilter$.value.map(id => { + const column = this.propertyGet(id); + return { + id: column.id, + hide: column.hide$.value, + width: column.width$.value, + statCalcType: column.statCalcOp$.value, + }; + }); + }); + + detailProperties$ = computed(() => { + return this.propertiesWithoutFilter$.value.filter( + id => this.propertyTypeGet(id) !== 'title' + ); + }); + + filter$ = computed(() => { + return this.data$.value?.filter ?? emptyFilterGroup; + }); + + groupBy$ = computed(() => { + return this.data$.value?.groupBy; + }); + + groupManager = new GroupManager(this.groupBy$, this, { + sortGroup: ids => + sortByManually( + ids, + v => v, + this.groupProperties.map(v => v.key) + ), + sortRow: (key, ids) => { + const property = this.groupProperties.find(v => v.key === key); + return sortByManually(ids, v => v, property?.manuallyCardSort ?? []); + }, + changeGroupSort: keys => { + const map = new Map(this.groupProperties.map(v => [v.key, v])); + this.dataUpdate(() => { + return { + groupProperties: keys.map(key => { + const property = map.get(key); + if (property) { + return property; + } + return { + key, + hide: false, + manuallyCardSort: [], + }; + }), + }; + }); + }, + changeRowSort: (groupKeys, groupKey, keys) => { + const map = new Map(this.groupProperties.map(v => [v.key, v])); + this.dataUpdate(() => { + return { + groupProperties: groupKeys.map(key => { + if (key === groupKey) { + const group = map.get(key); + return group + ? { + ...group, + manuallyCardSort: keys, + } + : { + key, + hide: false, + manuallyCardSort: keys, + }; + } else { + return ( + map.get(key) ?? { + key, + hide: false, + manuallyCardSort: [], + } + ); + } + }), + }; + }); + }, + }); + + mainProperties$ = computed(() => { + return ( + this.data$.value?.header ?? { + titleColumn: this.propertiesWithoutFilter$.value.find( + id => this.propertyTypeGet(id) === 'title' + ), + iconColumn: 'type', + } + ); + }); + + propertyIds$ = computed(() => { + return this.propertiesWithoutFilter$.value.filter( + id => !this.propertyHideGet(id) + ); + }); + + readonly$ = computed(() => { + return this.manager.readonly$.value; + }); + + get groupProperties() { + return this.data$.value?.groupProperties ?? []; + } + + get name(): string { + return this.data$.value?.name ?? ''; + } + + override get type(): string { + return this.data$.value?.mode ?? 'table'; + } + + constructor(viewManager: ViewManager, viewId: string) { + super(viewManager, viewId); + } + + changeGroup(columnId: string | undefined) { + if (columnId == null) { + this.dataUpdate(() => { + return { + groupBy: undefined, + }; + }); + return; + } + const column = this.propertyGet(columnId); + this.dataUpdate(_view => { + return { + groupBy: defaultGroupBy( + this.propertyMetaGet(column.type$.value), + column.id, + column.data$.value + ), + }; + }); + } + + columnGetStatCalcOp(columnId: string): StatCalcOpType { + return this.data$.value?.columns.find(v => v.id === columnId)?.statCalcType; + } + + columnGetWidth(columnId: string): number { + const column = this.data$.value?.columns.find(v => v.id === columnId); + if (column?.width != null) { + return column.width; + } + const type = this.propertyTypeGet(columnId); + if (type === 'title') { + return 260; + } + return DEFAULT_COLUMN_WIDTH; + } + + columnUpdateStatCalcOp(columnId: string, op?: string): void { + this.dataUpdate(() => { + return { + columns: this.computedColumns$.value.map(v => + v.id === columnId + ? { + ...v, + statCalcType: op, + } + : v + ), + }; + }); + } + + columnUpdateWidth(columnId: string, width: number): void { + this.dataUpdate(() => { + return { + columns: this.computedColumns$.value.map(v => + v.id === columnId + ? { + ...v, + width: width, + } + : v + ), + }; + }); + } + + filterSet(filter: FilterGroup): void { + this.dataUpdate(() => { + return { + filter, + }; + }); + } + + isShow(rowId: string): boolean { + if (this.filter$.value?.conditions.length) { + const rowMap = Object.fromEntries( + this.properties$.value.map(column => [ + column.id, + column.cellGet(rowId).jsonValue$.value, + ]) + ); + return evalFilter(this.filter$.value, rowMap); + } + return true; + } + + propertyGet(columnId: string): TableColumn { + return new TableColumn(this, columnId); + } + + propertyHideGet(columnId: string): boolean { + return ( + this.data$.value?.columns.find(v => v.id === columnId)?.hide ?? false + ); + } + + propertyHideSet(columnId: string, hide: boolean): void { + this.dataUpdate(() => { + return { + columns: this.computedColumns$.value.map(v => + v.id === columnId + ? { + ...v, + hide, + } + : v + ), + }; + }); + } + + propertyMove(columnId: string, toAfterOfColumn: InsertToPosition): void { + this.dataUpdate(() => { + const columnIndex = this.computedColumns$.value.findIndex( + v => v.id === columnId + ); + if (columnIndex < 0) { + return {}; + } + const columns = [...this.computedColumns$.value]; + const [column] = columns.splice(columnIndex, 1); + const index = insertPositionToIndex(toAfterOfColumn, columns); + columns.splice(index, 0, column); + return { + columns, + }; + }); + } + + override rowAdd( + insertPosition: InsertToPosition | number, + groupKey?: string + ): string { + const id = super.rowAdd(insertPosition); + if (!groupKey) { + return id; + } + this.groupManager.addToGroup(id, groupKey); + return id; + } + + override rowMove( + rowId: string, + position: InsertToPosition, + fromGroup?: string, + toGroup?: string + ) { + if (toGroup == null) { + super.rowMove(rowId, position); + return; + } + this.groupManager.moveCardTo(rowId, fromGroup, toGroup, position); + } + + override rowNextGet(rowId: string): string { + const index = this.rows$.value.indexOf(rowId); + return this.rows$.value[index + 1]; + } + + override rowPrevGet(rowId: string): string { + const index = this.rows$.value.indexOf(rowId); + return this.rows$.value[index - 1]; + } +} + +export class TableColumn extends PropertyBase { + statCalcOp$ = computed(() => { + return this.tableView.columnGetStatCalcOp(this.id); + }); + + width$: ReadonlySignal = computed(() => { + return this.tableView.columnGetWidth(this.id); + }); + + constructor( + private tableView: TableSingleView, + columnId: string + ) { + super(tableView as SingleView, columnId); + } + + updateStatCalcOp(type?: string): void { + return this.tableView.columnUpdateStatCalcOp(this.id, type); + } + + updateWidth(width: number): void { + this.tableView.columnUpdateWidth(this.id, width); + } +} diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/table-view.ts b/packages/affine/microsheet-data-view/src/view-presets/table/table-view.ts new file mode 100644 index 000000000000..feb0a2b724c7 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/table/table-view.ts @@ -0,0 +1,316 @@ +import { + menu, + popMenu, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { + insertPositionToIndex, + type InsertToPosition, +} from '@blocksuite/affine-shared/utils'; +import { AddCursorIcon } from '@blocksuite/icons/lit'; +import { css } from 'lit'; +import { styleMap } from 'lit/directives/style-map.js'; +import { html } from 'lit/static-html.js'; + +import type { GroupManager } from '../../core/common/group-by/helper.js'; +import type { DataViewExpose } from '../../core/index.js'; +import type { TableSingleView } from './table-view-manager.js'; + +import { renderUniLit } from '../../core/utils/uni-component/uni-component.js'; +import { DataViewBase } from '../../core/view/data-view-base.js'; +import { LEFT_TOOL_BAR_WIDTH } from './consts.js'; +import { TableClipboardController } from './controller/clipboard.js'; +import { TableDragController } from './controller/drag.js'; +import { TableHotkeysController } from './controller/hotkeys.js'; +import { TableSelectionController } from './controller/selection.js'; +import { + TableAreaSelection, + type TableViewSelectionWithType, +} from './types.js'; + +const styles = css` + affine-microsheet-table { + position: relative; + display: flex; + flex-direction: column; + } + + affine-microsheet-table * { + box-sizing: border-box; + } + + .affine-microsheet-table { + overflow-y: auto; + } + + .affine-microsheet-block-title-container { + display: flex; + align-items: center; + justify-content: space-between; + height: 44px; + margin: 2px 0 2px; + } + + .affine-microsheet-block-table { + position: relative; + width: 100%; + padding-bottom: 4px; + z-index: 1; + overflow-x: scroll; + overflow-y: hidden; + } + + .affine-microsheet-block-table:hover { + padding-bottom: 0px; + } + + .affine-microsheet-block-table::-webkit-scrollbar { + -webkit-appearance: none; + display: block; + } + + .affine-microsheet-block-table::-webkit-scrollbar:horizontal { + height: 4px; + } + + .affine-microsheet-block-table::-webkit-scrollbar-thumb { + border-radius: 2px; + background-color: transparent; + } + + .affine-microsheet-block-table:hover::-webkit-scrollbar:horizontal { + height: 8px; + } + + .affine-microsheet-block-table:hover::-webkit-scrollbar-thumb { + border-radius: 16px; + background-color: var(--affine-black-30); + } + + .affine-microsheet-block-table:hover::-webkit-scrollbar-track { + background-color: var(--affine-hover-color); + } + + .affine-microsheet-table-container { + position: relative; + width: fit-content; + min-width: 100%; + } + + .affine-microsheet-block-tag-circle { + width: 12px; + height: 12px; + border-radius: 50%; + display: inline-block; + } + + .affine-microsheet-block-tag { + display: inline-flex; + border-radius: 11px; + align-items: center; + padding: 0 8px; + cursor: pointer; + } + + .microsheet-cell { + border-left: 1px solid var(--affine-border-color); + } + + .data-view-table-left-bar { + display: flex; + align-items: center; + position: sticky; + z-index: 1; + left: 0; + width: ${LEFT_TOOL_BAR_WIDTH}px; + flex-shrink: 0; + } + + .affine-microsheet-block-rows { + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; + } +`; + +export class DataViewTable extends DataViewBase< + TableSingleView, + TableViewSelectionWithType +> { + static override styles = styles; + + private _addRow = ( + tableViewManager: TableSingleView, + position: InsertToPosition | number + ) => { + if (this.readonly) return; + + const index = + typeof position === 'number' + ? position + : insertPositionToIndex( + position, + this.props.view.rows$.value.map(id => ({ id })) + ); + tableViewManager.rowAdd(position); + requestAnimationFrame(() => { + this.selectionController.selection = TableAreaSelection.create({ + focus: { + rowIndex: index, + columnIndex: 0, + }, + isEditing: true, + }); + }); + }; + + clipboardController = new TableClipboardController(this); + + dragController = new TableDragController(this); + + selectionController = new TableSelectionController(this); + + expose: DataViewExpose = { + addRow: position => { + this._addRow(this.props.view, position); + }, + focusFirstCell: () => { + this.selectionController.focusFirstCell(); + }, + showIndicator: evt => { + return this.dragController.showIndicator(evt) != null; + }, + hideIndicator: () => { + this.dragController.dropPreview.remove(); + }, + moveTo: (id, evt) => { + const result = this.dragController.getInsertPosition(evt); + if (result) { + this.props.view.rowMove( + id, + result.position, + undefined, + result.groupKey + ); + } + }, + getSelection: () => { + return this.selectionController.selection; + }, + }; + + hotkeysController = new TableHotkeysController(this); + + onWheel = (event: WheelEvent) => { + if (event.metaKey || event.ctrlKey) { + return; + } + const ele = event.currentTarget; + if (ele instanceof HTMLElement) { + if (ele.scrollWidth === ele.clientWidth) { + return; + } + event.stopPropagation(); + } + }; + + renderAddGroup = (groupHelper: GroupManager) => { + const addGroup = groupHelper.addGroup; + if (!addGroup) { + return; + } + const add = (e: MouseEvent) => { + const ele = e.currentTarget as HTMLElement; + popMenu(popupTargetFromElement(ele), { + options: { + items: [ + menu.input({ + onComplete: text => { + const column = groupHelper.property$.value; + if (column) { + column.dataUpdate( + () => addGroup(text, column.data$.value) as never + ); + } + }, + }), + ], + }, + }); + }; + return html`
+
+
${AddCursorIcon()}
+
New Group
+
+
`; + }; + + private get readonly() { + return this.props.view.readonly$.value; + } + + private renderTable() { + const groups = this.props.view.groupManager.groupsDataList$.value; + if (groups) { + return html` +
+ ${groups.map(group => { + return html` `; + })} + ${this.renderAddGroup(this.props.view.groupManager)} +
+ `; + } + return html` `; + } + + override render() { + const vPadding = this.props.virtualPadding$.value; + const wrapperStyle = styleMap({ + marginLeft: `-${vPadding}px`, + marginRight: `-${vPadding}px`, + }); + const containerStyle = styleMap({ + paddingLeft: `${vPadding}px`, + paddingRight: `${vPadding}px`, + }); + return html` + ${renderUniLit(this.props.headerWidget, { + view: this.props.view, + viewMethods: this.expose, + })} +
+
+
+ ${this.renderTable()} +
+
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-microsheet-table': DataViewTable; + } +} diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/types.ts b/packages/affine/microsheet-data-view/src/view-presets/table/types.ts new file mode 100644 index 000000000000..588560b14ce4 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/table/types.ts @@ -0,0 +1,126 @@ +import { assertExists } from '@blocksuite/global/utils'; + +export type ColumnType = string; + +export interface Column< + Data extends Record = Record, +> { + id: string; + type: ColumnType; + name: string; + data: Data; +} + +export type StatCalcOpType = string | undefined; + +export const getTableContainer = (ele: HTMLElement) => { + const element = ele.closest( + '.affine-microsheet-table-container' + ) as HTMLElement; + assertExists(element); + return element; +}; +type WithTableViewType = T extends unknown + ? { + viewId: string; + type: 'table'; + } & T + : never; +export type RowWithGroup = { + id: string; + groupKey?: string; +}; +export const RowWithGroup = { + equal(a?: RowWithGroup, b?: RowWithGroup) { + if (a == null || b == null) { + return false; + } + return a.id === b.id && a.groupKey === b.groupKey; + }, +}; +export type TableRowSelection = { + selectionType: 'row'; + rows: RowWithGroup[]; +}; +export const TableRowSelection = { + rows: (selection?: TableViewSelection): RowWithGroup[] => { + if (selection?.selectionType === 'row') { + return selection.rows; + } + return []; + }, + rowsIds: (selection?: TableViewSelection): string[] => { + return TableRowSelection.rows(selection).map(v => v.id); + }, + includes( + selection: TableViewSelection | undefined, + row: RowWithGroup + ): boolean { + if (!selection) { + return false; + } + return TableRowSelection.rows(selection).some(v => + RowWithGroup.equal(v, row) + ); + }, + create(options: { rows: RowWithGroup[] }): TableRowSelection { + return { + selectionType: 'row', + rows: options.rows, + }; + }, + is(selection?: TableViewSelection): selection is TableRowSelection { + return selection?.selectionType === 'row'; + }, +}; +export type TableAreaSelection = { + selectionType: 'area'; + groupKey?: string; + rowsSelection: MultiSelection; + columnsSelection: MultiSelection; + focus: CellFocus; + isEditing: boolean; +}; +export const TableAreaSelection = { + create: (options: { + groupKey?: string; + focus: CellFocus; + rowsSelection?: MultiSelection; + columnsSelection?: MultiSelection; + isEditing: boolean; + }): TableAreaSelection => { + return { + ...options, + selectionType: 'area', + rowsSelection: options.rowsSelection ?? { + start: options.focus.rowIndex, + end: options.focus.rowIndex, + }, + columnsSelection: options.columnsSelection ?? { + start: options.focus.columnIndex, + end: options.focus.columnIndex, + }, + }; + }, + isFocus(selection: TableAreaSelection) { + return ( + selection.focus.rowIndex === selection.rowsSelection.start && + selection.focus.rowIndex === selection.rowsSelection.end && + selection.focus.columnIndex === selection.columnsSelection.start && + selection.focus.columnIndex === selection.columnsSelection.end + ); + }, +}; + +export type CellFocus = { + rowIndex: number; + columnIndex: number; +}; +export type MultiSelection = { + start: number; + end: number; +}; +export type TableViewSelection = TableAreaSelection | TableRowSelection; +export type TableViewSelectionWithType = WithTableViewType< + TableAreaSelection | TableRowSelection +>; diff --git a/packages/affine/microsheet-data-view/src/widget-presets/filter/condition.ts b/packages/affine/microsheet-data-view/src/widget-presets/filter/condition.ts new file mode 100644 index 000000000000..967d78a6866c --- /dev/null +++ b/packages/affine/microsheet-data-view/src/widget-presets/filter/condition.ts @@ -0,0 +1,250 @@ +import { + menu, + popFilterableSimpleMenu, + type PopupTarget, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher } from '@blocksuite/global/utils'; +import { CloseIcon } from '@blocksuite/icons/lit'; +import { computed } from '@preact/signals-core'; +import { css, html, nothing } from 'lit'; +import { property } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import { + type FilterGroup, + firstFilter, + firstFilterByRef, + firstFilterInGroup, + getRefType, + type SingleFilter, + type Variable, + type VariableOrProperty, +} from '../../core/common/ast.js'; +import { + popLiteralEdit, + renderLiteral, +} from '../../core/common/literal/matcher.js'; +import { tBoolean } from '../../core/logical/data-type.js'; +import { typesystem } from '../../core/logical/typesystem.js'; +import { filterMatcher } from './matcher/matcher.js'; + +export class FilterConditionView extends SignalWatcher(ShadowlessElement) { + static override styles = css` + microsheet-filter-condition-view { + display: flex; + align-items: center; + padding: 4px; + gap: 16px; + border: 1px solid var(--affine-border-color); + border-radius: 8px; + background-color: var(--affine-white); + } + + .filter-condition-expression { + display: flex; + align-items: center; + gap: 4px; + } + + .filter-condition-delete { + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + height: max-content; + cursor: pointer; + } + + .filter-condition-delete:hover { + background-color: var(--affine-hover-color); + } + + .filter-condition-delete svg { + width: 16px; + height: 16px; + } + + .filter-condition-function-name { + font-size: 12px; + line-height: 20px; + color: var(--affine-text-secondary-color); + padding: 2px 8px; + border-radius: 4px; + cursor: pointer; + } + + .filter-condition-function-name:hover { + background-color: var(--affine-hover-color); + } + + .filter-condition-arg { + font-size: 12px; + font-style: normal; + font-weight: 600; + padding: 0 4px; + height: 100%; + display: flex; + align-items: center; + } + `; + + private _setRef = (ref: VariableOrProperty) => { + this.setData(firstFilterByRef(this.vars, ref)); + }; + + private _args() { + const fn = filterMatcher.find(v => v.data.name === this.data.function); + if (!fn) { + return []; + } + const refType = getRefType(this.vars, this.data.left); + if (!refType) { + return []; + } + const type = typesystem.instance({}, [refType], tBoolean.create(), fn.type); + return type.args.slice(1); + } + + private _filterLabel() { + return filterMatcher.find(v => v.data.name === this.data.function)?.data + .label; + } + + private _filterList() { + const type = getRefType(this.vars, this.data.left); + if (!type) { + return []; + } + return filterMatcher.allMatchedData(type); + } + + private _selectFilter(e: MouseEvent) { + const target = e.currentTarget as HTMLElement; + const list = this._filterList(); + popFilterableSimpleMenu( + popupTargetFromElement(target), + list.map(v => { + const selected = v.name === this.data.function; + return menu.action({ + name: v.label, + isSelected: selected, + select: () => { + this.setData({ + ...this.data, + function: v.name, + }); + }, + }); + }) + ); + } + + override render() { + const data = this.data; + + return html` +
+ +
+ ${this._filterLabel()} +
+ ${repeat(this._args(), (type, i) => { + const value$ = computed(() => { + return this.data.args[i]?.value; + }); + const onChange = (value: unknown) => { + const newArr = this.data.args.slice(); + newArr[i] = { type: 'literal', value }; + this.setData({ + ...this.data, + args: newArr, + }); + }; + const click = (e: MouseEvent) => { + popLiteralEdit( + popupTargetFromElement(e.currentTarget as HTMLElement), + type, + value$, + onChange + ); + }; + return html`
+ ${renderLiteral(type, value$, onChange)} +
`; + })} +
+ ${this.onDelete + ? html`
+ ${CloseIcon()} +
` + : nothing} + `; + } + + @property({ attribute: false }) + accessor data!: SingleFilter; + + @property({ attribute: false }) + accessor onDelete: (() => void) | undefined = undefined; + + @property({ attribute: false }) + accessor setData!: (filter: SingleFilter) => void; + + @property({ attribute: false }) + accessor vars!: Variable[]; +} + +declare global { + interface HTMLElementTagNameMap { + 'microsheet-filter-condition-view': FilterConditionView; + } +} +export const popAddNewFilter = ( + target: PopupTarget, + props: { + value: FilterGroup; + onChange: (value: FilterGroup) => void; + vars: Variable[]; + } +) => { + popFilterableSimpleMenu(target, [ + menu.action({ + name: 'Add filter', + select: () => { + props.onChange({ + ...props.value, + conditions: [...props.value.conditions, firstFilter(props.vars)], + }); + }, + }), + menu.action({ + name: 'Add filter group', + select: () => { + props.onChange({ + ...props.value, + conditions: [ + ...props.value.conditions, + firstFilterInGroup(props.vars), + ], + }); + }, + }), + ]); +}; diff --git a/packages/affine/microsheet-data-view/src/widget-presets/filter/context.ts b/packages/affine/microsheet-data-view/src/widget-presets/filter/context.ts new file mode 100644 index 000000000000..cfba2e323315 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/widget-presets/filter/context.ts @@ -0,0 +1,7 @@ +import { signal, type Signal } from '@preact/signals-core'; + +import { createContextKey } from '../../core/index.js'; + +export const ShowFilterContextKey = createContextKey< + Signal> +>('show-filter', signal({})); diff --git a/packages/affine/microsheet-data-view/src/widget-presets/filter/filter-bar.ts b/packages/affine/microsheet-data-view/src/widget-presets/filter/filter-bar.ts new file mode 100644 index 000000000000..752a859517b4 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/widget-presets/filter/filter-bar.ts @@ -0,0 +1,253 @@ +import { + createPopup, + type PopupTarget, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher } from '@blocksuite/global/utils'; +import { CloseIcon, FilterIcon, PlusIcon } from '@blocksuite/icons/lit'; +import { computed, type ReadonlySignal } from '@preact/signals-core'; +import { css, html, type TemplateResult } from 'lit'; +import { property } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import type { Filter, FilterGroup, Variable } from '../../core/common/ast.js'; + +import { popCreateFilter } from '../../core/common/ref/ref.js'; +import { renderTemplate } from '../../core/utils/uni-component/render-template.js'; +import { popFilterGroup } from './filter-modal.js'; + +export class FilterBar extends SignalWatcher(ShadowlessElement) { + static override styles = css` + microsheet-filter-bar { + margin-top: 8px; + display: flex; + gap: 8px; + } + + .filter-group-tag { + font-size: 12px; + font-style: normal; + font-weight: 600; + line-height: 20px; + display: flex; + align-items: center; + padding: 4px; + background-color: var(--affine-white); + } + + .microsheet-filter-bar-add-filter { + color: var(--affine-text-secondary-color); + padding: 4px 8px; + display: flex; + align-items: center; + gap: 6px; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 22px; + } + `; + + private _setFilter = (index: number, filter: Filter) => { + this.onChange({ + ...this.filterGroup.value, + conditions: this.filterGroup.value.conditions.map((v, i) => + index === i ? filter : v + ), + }); + }; + + private addFilter = (e: MouseEvent) => { + const element = popupTargetFromElement(e.target as HTMLElement); + popCreateFilter(element, { + vars: this.vars, + onSelect: filter => { + const index = this.filterGroup.value.conditions.length; + this.onChange({ + ...this.filterGroup.value, + conditions: [...this.filterGroup.value.conditions, filter], + }); + requestAnimationFrame(() => { + this.expandGroup(element, index); + }); + }, + }); + }; + + private expandGroup = (position: PopupTarget, i: number) => { + if (this.filterGroup.value.conditions[i]?.type !== 'group') { + return; + } + popFilterGroup(position, { + vars: this.vars, + value$: computed(() => { + return this.filterGroup.value.conditions[i] as FilterGroup; + }), + onChange: filter => { + if (filter) { + this._setFilter(i, filter); + } else { + this.deleteFilter(i); + } + }, + }); + }; + + renderAddFilter = () => { + return html`
+ ${PlusIcon()} Add filter +
`; + }; + + renderMore = (count: number) => { + const max = this.filterGroup.value.conditions.length; + if (count === max) { + return this.renderAddFilter(); + } + const showMore = (e: MouseEvent) => { + this.showMoreFilter(e, count); + }; + return html`
+ ${max - count} More +
`; + }; + + renderMoreFilter = (count: number): TemplateResult => { + return html`
+ ${repeat( + this.filterGroup.value.conditions.slice(count), + (_, i) => + html`
+ ${this.renderCondition(i + count)} +
` + )} +
+ ${this.renderAddFilter()} +
`; + }; + + showMoreFilter = (e: MouseEvent, count: number) => { + const ins = renderTemplate(() => this.renderMoreFilter(count)); + ins.style.position = 'absolute'; + this.updateMoreFilterPanel = () => { + const max = this.filterGroup.value.conditions.length; + if (count === max) { + close(); + this.updateMoreFilterPanel = undefined; + return; + } + ins.requestUpdate(); + }; + const close = createPopup( + popupTargetFromElement(e.target as HTMLElement), + ins, + { + onClose: () => { + this.updateMoreFilterPanel = undefined; + }, + } + ); + }; + + updateMoreFilterPanel?: () => void; + + private deleteFilter(i: number) { + this.onChange({ + ...this.filterGroup.value, + conditions: this.filterGroup.value.conditions.filter( + (_, index) => index !== i + ), + }); + } + + override render() { + return html` + + `; + } + + renderCondition(i: number) { + const condition = this.filterGroup.value.conditions[i]; + const deleteFilter = () => { + this.deleteFilter(i); + }; + if (!condition) { + return; + } + if (condition.type === 'filter') { + return html` `; + } + const expandGroup = (e: MouseEvent) => { + const element = (e.currentTarget as HTMLElement) + .parentElement as HTMLElement; + this.expandGroup(popupTargetFromElement(element), i); + }; + const length = condition.conditions.length; + const text = length > 1 ? `${length} rules` : `${length} rule`; + return html`
+
+ ${FilterIcon()} ${text} +
+
+ ${CloseIcon()} +
+
`; + } + + renderFilters() { + return this.filterGroup.value.conditions.map( + (_, i) => () => this.renderCondition(i) + ); + } + + override updated() { + this.updateMoreFilterPanel?.(); + } + + @property({ attribute: false }) + accessor filterGroup!: ReadonlySignal; + + @property({ attribute: false }) + accessor onChange!: (filter: FilterGroup) => void; + + @property({ attribute: false }) + accessor vars!: ReadonlySignal; +} + +declare global { + interface HTMLElementTagNameMap { + 'microsheet-filter-bar': FilterBar; + } +} diff --git a/packages/affine/microsheet-data-view/src/widget-presets/filter/filter-group.ts b/packages/affine/microsheet-data-view/src/widget-presets/filter/filter-group.ts new file mode 100644 index 000000000000..2866f8041c01 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/widget-presets/filter/filter-group.ts @@ -0,0 +1,384 @@ +import type { TemplateResult } from 'lit'; + +import { + menu, + popFilterableSimpleMenu, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher } from '@blocksuite/global/utils'; +import { + ArrowDownSmallIcon, + ConvertIcon, + DeleteIcon, + DuplicateIcon, + MoreHorizontalIcon, + PlusIcon, +} from '@blocksuite/icons/lit'; +import { computed, type ReadonlySignal } from '@preact/signals-core'; +import { css, html, nothing } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import { + type Filter, + type FilterGroup, + firstFilter, + type Variable, +} from '../../core/common/ast.js'; +import { popAddNewFilter } from './condition.js'; + +export class FilterGroupView extends SignalWatcher(ShadowlessElement) { + static override styles = css` + microsheet-filter-group-view { + border-radius: 4px; + display: flex; + flex-direction: column; + user-select: none; + } + + .filter-group-op { + width: 60px; + display: flex; + justify-content: end; + padding: 4px; + height: 34px; + align-items: center; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 22px; + color: var(--affine-text-primary-color); + } + + .filter-group-op-clickable { + border-radius: 4px; + cursor: pointer; + } + + .filter-group-op-clickable:hover { + background-color: var(--affine-hover-color); + } + + .filter-group-container { + display: flex; + flex-direction: column; + gap: 2px; + } + + .filter-group-button { + padding: 8px 12px; + display: flex; + align-items: center; + gap: 6px; + font-size: 14px; + line-height: 22px; + border-radius: 4px; + cursor: pointer; + color: var(--affine-text-secondary-color); + } + + .filter-group-button svg { + fill: var(--affine-text-secondary-color); + color: var(--affine-text-secondary-color); + width: 20px; + height: 20px; + } + + .filter-group-button:hover { + background-color: var(--affine-hover-color); + color: var(--affine-text-primary-color); + } + + .filter-group-button:hover svg { + fill: var(--affine-text-primary-color); + color: var(--affine-text-primary-color); + } + + .filter-group-item { + padding: 4px 0; + display: flex; + align-items: start; + gap: 8px; + } + + .filter-group-item-ops { + margin-top: 4px; + padding: 4px; + border-radius: 4px; + height: max-content; + display: flex; + cursor: pointer; + } + + .filter-group-item-ops:hover { + background-color: var(--affine-hover-color); + } + + .filter-group-item-ops svg { + fill: var(--affine-text-secondary-color); + color: var(--affine-text-secondary-color); + width: 18px; + height: 18px; + } + + .filter-group-item-ops:hover svg { + fill: var(--affine-text-primary-color); + color: var(--affine-text-primary-color); + } + + .delete-style { + background-color: var(--affine-background-error-color); + } + + .filter-group-border { + border: 1px dashed var(--affine-border-color); + } + + .filter-group-bg-1 { + background-color: var(--affine-background-secondary-color); + border: 1px solid var(--affine-border-color); + } + + .filter-group-bg-2 { + background-color: var(--affine-background-tertiary-color); + border: 1px solid var(--affine-border-color); + } + + .hover-style { + background-color: var(--affine-hover-color); + } + + .delete-style { + background-color: var(--affine-background-error-color); + } + `; + + private _addNew = (e: MouseEvent) => { + if (this.isMaxDepth) { + this.onChange({ + ...this.filterGroup.value, + conditions: [ + ...this.filterGroup.value.conditions, + firstFilter(this.vars.value), + ], + }); + return; + } + popAddNewFilter(popupTargetFromElement(e.currentTarget as HTMLElement), { + value: this.filterGroup.value, + onChange: this.onChange, + vars: this.vars.value, + }); + }; + + private _selectOp = (event: MouseEvent) => { + popFilterableSimpleMenu( + popupTargetFromElement(event.currentTarget as HTMLElement), + [ + menu.action({ + name: 'And', + select: () => { + this.onChange({ + ...this.filterGroup.value, + op: 'and', + }); + }, + }), + menu.action({ + name: 'Or', + select: () => { + this.onChange({ + ...this.filterGroup.value, + op: 'or', + }); + }, + }), + ] + ); + }; + + private _setFilter = (index: number, filter: Filter) => { + this.onChange({ + ...this.filterGroup.value, + conditions: this.filterGroup.value.conditions.map((v, i) => + index === i ? filter : v + ), + }); + }; + + private opMap = { + and: 'And', + or: 'Or', + }; + + private get isMaxDepth() { + return this.depth === 3; + } + + private _clickConditionOps(target: HTMLElement, i: number) { + const filter = this.filterGroup.value.conditions[i]; + popFilterableSimpleMenu(popupTargetFromElement(target), [ + menu.action({ + name: filter.type === 'filter' ? 'Turn into group' : 'Wrap in group', + prefix: ConvertIcon(), + onHover: hover => { + this.containerClass = hover + ? { index: i, class: 'hover-style' } + : undefined; + }, + hide: () => this.depth + getDepth(filter) > 3, + select: () => { + this.onChange({ + type: 'group', + op: 'and', + conditions: [this.filterGroup.value], + }); + }, + }), + menu.action({ + name: 'Duplicate', + prefix: DuplicateIcon(), + onHover: hover => { + this.containerClass = hover + ? { index: i, class: 'hover-style' } + : undefined; + }, + select: () => { + const conditions = [...this.filterGroup.value.conditions]; + conditions.splice( + i + 1, + 0, + JSON.parse(JSON.stringify(conditions[i])) + ); + this.onChange({ ...this.filterGroup.value, conditions: conditions }); + }, + }), + menu.group({ + name: '', + items: [ + menu.action({ + name: 'Delete', + prefix: DeleteIcon(), + class: 'delete-item', + onHover: hover => { + this.containerClass = hover + ? { index: i, class: 'delete-style' } + : undefined; + }, + select: () => { + const conditions = [...this.filterGroup.value.conditions]; + conditions.splice(i, 1); + this.onChange({ + ...this.filterGroup.value, + conditions, + }); + }, + }), + ], + }), + ]); + } + + override render() { + const data = this.filterGroup.value; + return html` +
+ ${repeat(data.conditions, (filter, i) => { + const clickOps = (e: MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + this._clickConditionOps(e.target as HTMLElement, i); + }; + let op: TemplateResult; + if (i === 0) { + op = html`
Where
`; + } else { + op = html` +
+ ${this.opMap[data.op]} +
+ `; + } + const classList = classMap({ + 'filter-root-item': true, + 'filter-exactly-hover-container': true, + 'dv-pd-4 dv-round-4': true, + [this.containerClass?.class ?? '']: + this.containerClass?.index === i, + }); + const groupClassList = classMap({ + [`filter-group-bg-${this.depth}`]: filter.type !== 'filter', + }); + return html`
+ ${op} +
+ ${filter.type === 'filter' + ? html` + + ` + : html` + + `} +
+ ${MoreHorizontalIcon()} +
+
+
`; + })} +
+
+ ${PlusIcon()} Add ${this.isMaxDepth ? nothing : ArrowDownSmallIcon()} +
+ `; + } + + @state() + accessor containerClass: + | { + index: number; + class: string; + } + | undefined = undefined; + + @property({ attribute: false }) + accessor depth = 1; + + @property({ attribute: false }) + accessor filterGroup!: ReadonlySignal; + + @property({ attribute: false }) + accessor onChange!: (filter: FilterGroup) => void; + + @property({ attribute: false }) + accessor vars!: ReadonlySignal; +} + +declare global { + interface HTMLElementTagNameMap { + 'microsheet-filter-group-view': FilterGroupView; + } +} +export const getDepth = (filter: Filter): number => { + if (filter.type === 'filter') { + return 1; + } + return Math.max(...filter.conditions.map(getDepth)) + 1; +}; diff --git a/packages/affine/microsheet-data-view/src/widget-presets/filter/filter-modal.ts b/packages/affine/microsheet-data-view/src/widget-presets/filter/filter-modal.ts new file mode 100644 index 000000000000..934645e0bc64 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/widget-presets/filter/filter-modal.ts @@ -0,0 +1,115 @@ +import type { ReadonlySignal } from '@preact/signals-core'; + +import { + menu, + popMenu, + type PopupTarget, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { DeleteIcon, PlusIcon } from '@blocksuite/icons/lit'; +import { html } from 'lit'; + +import type { SingleView } from '../../core/index.js'; + +import { + emptyFilterGroup, + type FilterGroup, + type Variable, +} from '../../core/common/ast.js'; +import { popAddNewFilter } from './condition.js'; + +export const popFilterRoot = ( + target: PopupTarget, + props: { + view: SingleView; + onBack: () => void; + } +) => { + popMenu(target, { + options: { + title: { + text: 'Filters', + onBack: props.onBack, + }, + items: [ + menu.group({ + items: [ + () => { + const view = props.view; + const onChange = view.filterSet.bind(view); + return html` `; + }, + ], + }), + menu.group({ + items: [ + menu.action({ + name: 'Add', + prefix: PlusIcon(), + select: ele => { + const view = props.view; + const vars = view.vars$.value; + const value = view.filter$.value ?? emptyFilterGroup; + const onChange = view.filterSet.bind(view); + popAddNewFilter(popupTargetFromElement(ele), { + value: value, + onChange: onChange, + vars: vars, + }); + return false; + }, + }), + ], + }), + ], + }, + }); +}; +export const popFilterGroup = ( + target: PopupTarget, + props: { + vars: ReadonlySignal; + value$: ReadonlySignal; + onChange: (value?: FilterGroup) => void; + onBack?: () => void; + } +) => { + popMenu(target, { + options: { + title: { + text: 'Filter group', + onBack: props.onBack, + }, + items: [ + menu.group({ + items: [ + () => { + return html` `; + }, + ], + }), + menu.group({ + items: [ + menu.action({ + name: 'Delete', + class: 'delete-item', + prefix: DeleteIcon(), + select: () => { + props.onChange(); + }, + }), + ], + }), + ], + }, + }); +}; diff --git a/packages/affine/microsheet-data-view/src/widget-presets/filter/filter-root.ts b/packages/affine/microsheet-data-view/src/widget-presets/filter/filter-root.ts new file mode 100644 index 000000000000..795f0d819baa --- /dev/null +++ b/packages/affine/microsheet-data-view/src/widget-presets/filter/filter-root.ts @@ -0,0 +1,318 @@ +import { + menu, + popFilterableSimpleMenu, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher } from '@blocksuite/global/utils'; +import { + ConvertIcon, + DeleteIcon, + DuplicateIcon, + MoreHorizontalIcon, +} from '@blocksuite/icons/lit'; +import { computed, type ReadonlySignal } from '@preact/signals-core'; +import { css, html, nothing } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import type { Filter, FilterGroup, Variable } from '../../core/common/ast.js'; +import type { FilterGroupView } from './filter-group.js'; + +import { getDepth } from './filter-group.js'; + +export class FilterRootView extends SignalWatcher(ShadowlessElement) { + static override styles = css` + .filter-root-title { + padding: 12px; + font-size: 14px; + font-weight: 600; + line-height: 22px; + color: var(--affine-text-primary-color); + } + + .filter-root-op { + width: 60px; + display: flex; + justify-content: end; + padding: 4px; + height: 34px; + align-items: center; + } + + .filter-root-op-clickable { + border-radius: 4px; + cursor: pointer; + } + + .filter-root-op-clickable:hover { + background-color: var(--affine-hover-color); + } + + .filter-root-container { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 400px; + overflow: auto; + } + + .filter-root-button { + margin: 4px 8px 8px; + padding: 8px 12px; + display: flex; + align-items: center; + gap: 6px; + font-size: 14px; + line-height: 22px; + border-radius: 4px; + cursor: pointer; + color: var(--affine-text-secondary-color); + } + + .filter-root-button svg { + fill: var(--affine-text-secondary-color); + color: var(--affine-text-secondary-color); + width: 20px; + height: 20px; + } + + .filter-root-button:hover { + background-color: var(--affine-hover-color); + color: var(--affine-text-primary-color); + } + .filter-root-button:hover svg { + fill: var(--affine-text-primary-color); + color: var(--affine-text-primary-color); + } + + .filter-root-item { + padding: 4px 0; + display: flex; + align-items: start; + gap: 8px; + } + + .filter-group-title { + font-size: 14px; + font-style: normal; + font-weight: 500; + line-height: 22px; + display: flex; + align-items: center; + color: var(--affine-text-primary-color); + gap: 6px; + } + + .filter-root-item-ops { + margin-top: 2px; + padding: 4px; + border-radius: 4px; + height: max-content; + display: flex; + cursor: pointer; + } + + .filter-root-item-ops:hover { + background-color: var(--affine-hover-color); + } + + .filter-root-item-ops svg { + fill: var(--affine-text-secondary-color); + color: var(--affine-text-secondary-color); + width: 18px; + height: 18px; + } + .filter-root-item-ops:hover svg { + fill: var(--affine-text-primary-color); + color: var(--affine-text-primary-color); + } + + .filter-root-grabber { + cursor: grab; + width: 4px; + height: 12px; + background-color: var(--affine-placeholder-color); + border-radius: 1px; + } + + .divider { + height: 1px; + background-color: var(--affine-divider-color); + flex-shrink: 0; + margin: 8px 0; + } + `; + + private _setFilter = (index: number, filter: Filter) => { + this.onChange({ + ...this.filterGroup.value, + conditions: this.filterGroup.value.conditions.map((v, i) => + index === i ? filter : v + ), + }); + }; + + private _clickConditionOps(target: HTMLElement, i: number) { + const filter = this.filterGroup.value.conditions[i]; + popFilterableSimpleMenu(popupTargetFromElement(target), [ + menu.action({ + name: filter.type === 'filter' ? 'Turn into group' : 'Wrap in group', + prefix: ConvertIcon(), + onHover: hover => { + this.containerClass = hover + ? { index: i, class: 'hover-style' } + : undefined; + }, + hide: () => getDepth(filter) > 3, + select: () => { + this.onChange({ + type: 'group', + op: 'and', + conditions: [this.filterGroup.value], + }); + }, + }), + menu.action({ + name: 'Duplicate', + prefix: DuplicateIcon(), + onHover: hover => { + this.containerClass = hover + ? { index: i, class: 'hover-style' } + : undefined; + }, + select: () => { + const conditions = [...this.filterGroup.value.conditions]; + conditions.splice( + i + 1, + 0, + JSON.parse(JSON.stringify(conditions[i])) + ); + this.onChange({ ...this.filterGroup.value, conditions: conditions }); + }, + }), + menu.group({ + name: '', + items: [ + menu.action({ + name: 'Delete', + prefix: DeleteIcon(), + class: 'delete-item', + onHover: hover => { + this.containerClass = hover + ? { index: i, class: 'delete-style' } + : undefined; + }, + select: () => { + const conditions = [...this.filterGroup.value.conditions]; + conditions.splice(i, 1); + this.onChange({ + ...this.filterGroup.value, + conditions, + }); + }, + }), + ], + }), + ]); + } + + override render() { + const data = this.filterGroup.value; + return html` +
+ ${repeat(data.conditions, (filter, i) => { + const clickOps = (e: MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + this._clickConditionOps(e.target as HTMLElement, i); + }; + const ops = html` +
+ ${MoreHorizontalIcon()} +
+ `; + const content = + filter.type === 'filter' + ? html` +
+
+
+ +
+ ${ops} +
+ ` + : html` +
+
+
+
+ Filter group +
+ ${ops} +
+
+ +
+
+ `; + const classList = classMap({ + 'filter-root-item': true, + 'filter-exactly-hover-container': true, + 'dv-pd-4 dv-round-4': true, + [this.containerClass?.class ?? '']: + this.containerClass?.index === i, + }); + return html` ${data.conditions[i - 1]?.type === 'group' || + filter.type === 'group' + ? html`
` + : nothing} +
+ ${content} +
`; + })} +
+ `; + } + + @state() + accessor containerClass: + | { + index: number; + class: string; + } + | undefined = undefined; + + @property({ attribute: false }) + accessor filterGroup!: ReadonlySignal; + + @property({ attribute: false }) + accessor onBack!: () => void; + + @property({ attribute: false }) + accessor onChange!: (filter: FilterGroup) => void; + + @property({ attribute: false }) + accessor vars!: ReadonlySignal; +} + +declare global { + interface HTMLElementTagNameMap { + 'microsheet-filter-root-view': FilterGroupView; + } +} diff --git a/packages/affine/microsheet-data-view/src/widget-presets/filter/index.ts b/packages/affine/microsheet-data-view/src/widget-presets/filter/index.ts new file mode 100644 index 000000000000..1efeb48deff4 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/widget-presets/filter/index.ts @@ -0,0 +1,23 @@ +import { html } from 'lit'; + +import type { DataViewWidgetProps } from '../../core/widget/types.js'; + +import { defineUniComponent } from '../../core/index.js'; +import { ShowFilterContextKey } from './context.js'; + +export const widgetFilterBar = defineUniComponent( + (props: DataViewWidgetProps) => { + const view = props.view; + if ( + view.filter$.value.conditions.length <= 0 || + !view.contextGet(ShowFilterContextKey).value[view.id] + ) { + return html``; + } + return html` `; + } +); diff --git a/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/boolean.ts b/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/boolean.ts new file mode 100644 index 000000000000..45b7c70eeb27 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/boolean.ts @@ -0,0 +1,21 @@ +import type { FilterDefineType } from './matcher.js'; + +import { tBoolean } from '../../../core/logical/data-type.js'; +import { tFunction } from '../../../core/logical/typesystem.js'; + +export const booleanFilter = { + isChecked: { + type: tFunction({ args: [tBoolean.create()], rt: tBoolean.create() }), + label: 'Is checked', + impl: value => { + return !!value; + }, + }, + isUnchecked: { + type: tFunction({ args: [tBoolean.create()], rt: tBoolean.create() }), + label: 'Is unchecked', + impl: value => { + return !value; + }, + }, +} satisfies Record; diff --git a/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/date.ts b/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/date.ts new file mode 100644 index 000000000000..4358ea24f084 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/date.ts @@ -0,0 +1,33 @@ +import type { FilterDefineType } from './matcher.js'; + +import { tBoolean, tDate } from '../../../core/logical/data-type.js'; +import { tFunction } from '../../../core/logical/typesystem.js'; + +export const dateFilter = { + before: { + type: tFunction({ + args: [tDate.create(), tDate.create()], + rt: tBoolean.create(), + }), + label: 'Before', + impl: (value, target) => { + if (typeof value !== 'number' || typeof target !== 'number') { + return true; + } + return value < target; + }, + }, + after: { + type: tFunction({ + args: [tDate.create(), tDate.create()], + rt: tBoolean.create(), + }), + label: 'After', + impl: (value, target) => { + if (typeof value !== 'number' || typeof target !== 'number') { + return true; + } + return value > target; + }, + }, +} as Record; diff --git a/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/matcher.ts b/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/matcher.ts new file mode 100644 index 000000000000..7121d615d878 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/matcher.ts @@ -0,0 +1,56 @@ +import { Matcher, MatcherCreator } from '../../../core/logical/matcher.js'; +import { + type TFunction, + typesystem, +} from '../../../core/logical/typesystem.js'; +import { booleanFilter } from './boolean.js'; +import { dateFilter } from './date.js'; +import { multiTagFilter } from './multi-tag.js'; +import { numberFilter } from './number.js'; +import { stringFilter } from './string.js'; +import { tagFilter } from './tag.js'; +import { unknownFilter } from './unknown.js'; + +export type FilterMatcherDataType = { + name: string; + label: string; + impl: (...args: unknown[]) => boolean; +}; +export type FilterDefineType = { + type: TFunction; +} & Omit; +const allFilter = { + ...dateFilter, + ...multiTagFilter, + ...numberFilter, + ...stringFilter, + ...tagFilter, + ...booleanFilter, + ...unknownFilter, +}; +const filterMatcherCreator = new MatcherCreator< + FilterMatcherDataType, + TFunction +>(); +const filterMatchers = Object.entries(allFilter).map( + ([name, { type, ...data }]) => { + return filterMatcherCreator.createMatcher(type, { + name: name, + ...data, + }); + } +); +export const filterMatcher = new Matcher( + filterMatchers, + (type, target) => { + if (type.type !== 'function') { + return false; + } + const staticType = typesystem.subst( + Object.fromEntries(type.typeVars?.map(v => [v.name, v.bound]) ?? []), + type + ); + const firstArg = staticType.args[0]; + return firstArg && typesystem.isSubtype(firstArg, target); + } +); diff --git a/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/multi-tag.ts b/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/multi-tag.ts new file mode 100644 index 000000000000..fd1551f71676 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/multi-tag.ts @@ -0,0 +1,69 @@ +import type { FilterDefineType } from './matcher.js'; + +import { tBoolean, tTag } from '../../../core/logical/data-type.js'; +import { + tArray, + tFunction, + tTypeRef, + tTypeVar, +} from '../../../core/logical/typesystem.js'; + +export const multiTagFilter = { + containsAll: { + type: tFunction({ + typeVars: [tTypeVar('options', tTag.create())], + args: [tArray(tTypeRef('options')), tArray(tTypeRef('options'))], + rt: tBoolean.create(), + }), + label: 'Contains all', + impl: (value, target) => { + if (!Array.isArray(target) || !Array.isArray(value) || !target.length) { + return true; + } + return target.every(v => value.includes(v)); + }, + }, + containsOneOf: { + type: tFunction({ + typeVars: [tTypeVar('options', tTag.create())], + args: [tArray(tTypeRef('options')), tArray(tTypeRef('options'))], + rt: tBoolean.create(), + }), + name: 'containsOneOf', + label: 'Contains one of', + impl: (value, target) => { + if (!Array.isArray(target) || !Array.isArray(value) || !target.length) { + return true; + } + return target.some(v => value.includes(v)); + }, + }, + doesNotContainsOneOf: { + type: tFunction({ + typeVars: [tTypeVar('options', tTag.create())], + args: [tArray(tTypeRef('options')), tArray(tTypeRef('options'))], + rt: tBoolean.create(), + }), + label: 'Does not contains one of', + impl: (value, target) => { + if (!Array.isArray(target) || !Array.isArray(value) || !target.length) { + return true; + } + return target.every(v => !value.includes(v)); + }, + }, + doesNotContainsAll: { + type: tFunction({ + typeVars: [tTypeVar('options', tTag.create())], + args: [tArray(tTypeRef('options')), tArray(tTypeRef('options'))], + rt: tBoolean.create(), + }), + label: 'Does not contains all', + impl: (value, target) => { + if (!Array.isArray(target) || !Array.isArray(value) || !target.length) { + return true; + } + return !target.every(v => value.includes(v)); + }, + }, +} as Record; diff --git a/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/number.ts b/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/number.ts new file mode 100644 index 000000000000..8a2e783d59f7 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/number.ts @@ -0,0 +1,91 @@ +import type { FilterDefineType } from './matcher.js'; + +import { tBoolean, tNumber } from '../../../core/logical/data-type.js'; +import { tFunction } from '../../../core/logical/typesystem.js'; + +export const numberFilter = { + greatThan: { + type: tFunction({ + args: [tNumber.create(), tNumber.create()], + rt: tBoolean.create(), + }), + label: '>', + impl: (value, target) => { + value = value ?? 0; + if (typeof value !== 'number' || typeof target !== 'number') { + return true; + } + return value > target; + }, + }, + greatThanOrEqual: { + type: tFunction({ + args: [tNumber.create(), tNumber.create()], + rt: tBoolean.create(), + }), + label: '>=', + impl: (value, target) => { + value = value ?? 0; + if (typeof value !== 'number' || typeof target !== 'number') { + return true; + } + return value >= target; + }, + }, + lessThan: { + type: tFunction({ + args: [tNumber.create(), tNumber.create()], + rt: tBoolean.create(), + }), + label: '<', + impl: (value, target) => { + value = value ?? 0; + if (typeof value !== 'number' || typeof target !== 'number') { + return true; + } + return value < target; + }, + }, + lessThanOrEqual: { + type: tFunction({ + args: [tNumber.create(), tNumber.create()], + rt: tBoolean.create(), + }), + label: '<=', + impl: (value, target) => { + value = value ?? 0; + if (typeof value !== 'number' || typeof target !== 'number') { + return true; + } + return value <= target; + }, + }, + equal: { + type: tFunction({ + args: [tNumber.create(), tNumber.create()], + rt: tBoolean.create(), + }), + label: '==', + impl: (value, target) => { + value = value ?? 0; + if (typeof value !== 'number' || typeof target !== 'number') { + return true; + } + return value == target; + }, + }, + notEqual: { + type: tFunction({ + args: [tNumber.create(), tNumber.create()], + rt: tBoolean.create(), + }), + label: '!=', + impl: (value, target) => { + value = value ?? 0; + if (typeof value !== 'number' || typeof target !== 'number') { + return true; + } + return value != target; + }, + }, +} as Record; diff --git a/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/string.ts b/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/string.ts new file mode 100644 index 000000000000..342558de6696 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/string.ts @@ -0,0 +1,109 @@ +import type { FilterDefineType } from './matcher.js'; + +import { tBoolean, tString } from '../../../core/logical/data-type.js'; +import { tFunction } from '../../../core/logical/typesystem.js'; + +export const stringFilter = { + is: { + type: tFunction({ + args: [tString.create(), tString.create()], + rt: tBoolean.create(), + }), + label: 'Is', + impl: (value, target) => { + if ( + typeof value !== 'string' || + typeof target !== 'string' || + target === '' + ) { + return true; + } + return value == target; + }, + }, + isNot: { + type: tFunction({ + args: [tString.create(), tString.create()], + rt: tBoolean.create(), + }), + label: 'Is not', + impl: (value, target) => { + if ( + typeof value !== 'string' || + typeof target !== 'string' || + target === '' + ) { + return true; + } + return value != target; + }, + }, + contains: { + type: tFunction({ + args: [tString.create(), tString.create()], + rt: tBoolean.create(), + }), + label: 'Contains', + impl: (value, target) => { + if ( + typeof value !== 'string' || + typeof target !== 'string' || + target === '' + ) { + return true; + } + return value.includes(target); + }, + }, + doesNoContains: { + type: tFunction({ + args: [tString.create(), tString.create()], + rt: tBoolean.create(), + }), + label: 'Does no contains', + impl: (value, target) => { + if ( + typeof value !== 'string' || + typeof target !== 'string' || + target === '' + ) { + return true; + } + return !value.includes(target); + }, + }, + startsWith: { + type: tFunction({ + args: [tString.create(), tString.create()], + rt: tBoolean.create(), + }), + label: 'Starts with', + impl: (value, target) => { + if ( + typeof value !== 'string' || + typeof target !== 'string' || + target === '' + ) { + return true; + } + return value.startsWith(target); + }, + }, + endsWith: { + type: tFunction({ + args: [tString.create(), tString.create()], + rt: tBoolean.create(), + }), + label: 'Ends with', + impl: (value, target) => { + if ( + typeof value !== 'string' || + typeof target !== 'string' || + target === '' + ) { + return true; + } + return value.endsWith(target); + }, + }, +} as Record; diff --git a/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/tag.ts b/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/tag.ts new file mode 100644 index 000000000000..3677d9471c33 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/tag.ts @@ -0,0 +1,40 @@ +import type { FilterDefineType } from './matcher.js'; + +import { tBoolean, tTag } from '../../../core/logical/data-type.js'; +import { + tArray, + tFunction, + tTypeRef, + tTypeVar, +} from '../../../core/logical/typesystem.js'; + +export const tagFilter = { + isOneOf: { + type: tFunction({ + typeVars: [tTypeVar('options', tTag.create())], + args: [tTypeRef('options'), tArray(tTypeRef('options'))], + rt: tBoolean.create(), + }), + label: 'Is one of', + impl: (value, target) => { + if (!Array.isArray(target) || !target.length) { + return true; + } + return target.includes(value); + }, + }, + isNotOneOf: { + type: tFunction({ + typeVars: [tTypeVar('options', tTag.create())], + args: [tTypeRef('options'), tArray(tTypeRef('options'))], + rt: tBoolean.create(), + }), + label: 'Is not one of', + impl: (value, target) => { + if (!Array.isArray(target) || !target.length) { + return true; + } + return !target.includes(value); + }, + }, +} as Record; diff --git a/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/unknown.ts b/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/unknown.ts new file mode 100644 index 000000000000..4b0893bb45a0 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/unknown.ts @@ -0,0 +1,33 @@ +import type { FilterDefineType } from './matcher.js'; + +import { tBoolean } from '../../../core/logical/data-type.js'; +import { tFunction, tUnknown } from '../../../core/logical/typesystem.js'; + +export const unknownFilter = { + isNotEmpty: { + type: tFunction({ args: [tUnknown.create()], rt: tBoolean.create() }), + label: 'Is not empty', + impl: value => { + if (Array.isArray(value)) { + return value.length > 0; + } + if (typeof value === 'string') { + return !!value; + } + return value != null; + }, + }, + isEmpty: { + type: tFunction({ args: [tUnknown.create()], rt: tBoolean.create() }), + label: 'Is empty', + impl: value => { + if (Array.isArray(value)) { + return value.length === 0; + } + if (typeof value === 'string') { + return !value; + } + return value == null; + }, + }, +} satisfies Record; diff --git a/packages/affine/microsheet-data-view/src/widget-presets/index.ts b/packages/affine/microsheet-data-view/src/widget-presets/index.ts new file mode 100644 index 000000000000..c3fe5954fb5f --- /dev/null +++ b/packages/affine/microsheet-data-view/src/widget-presets/index.ts @@ -0,0 +1,10 @@ +import { widgetFilterBar } from './filter/index.js'; +import { createWidgetTools, toolsWidgetPresets } from './tools/index.js'; +import { widgetViewsBar } from './views-bar/index.js'; + +export const widgetPresets = { + viewBar: widgetViewsBar, + filterBar: widgetFilterBar, + createTools: createWidgetTools, + tools: toolsWidgetPresets, +}; diff --git a/packages/affine/microsheet-data-view/src/widget-presets/tools/index.ts b/packages/affine/microsheet-data-view/src/widget-presets/tools/index.ts new file mode 100644 index 000000000000..190345055d93 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/widget-presets/tools/index.ts @@ -0,0 +1,32 @@ +import type { + DataViewWidget, + DataViewWidgetProps, +} from '../../core/widget/types.js'; + +import { createUniComponentFromWebComponent } from '../../core/index.js'; +import { uniMap } from '../../core/utils/uni-component/operation.js'; +import { DataViewHeaderToolsFilter } from './presets/filter/filter.js'; +import { DataViewHeaderToolsSearch } from './presets/search/search.js'; +import { DataViewHeaderToolsAddRow } from './presets/table-add-row/add-row.js'; +import { DataViewHeaderToolsViewOptions } from './presets/view-options/view-options.js'; +import { DataViewHeaderTools } from './tools-renderer.js'; + +export const toolsWidgetPresets = { + filter: createUniComponentFromWebComponent(DataViewHeaderToolsFilter), + search: createUniComponentFromWebComponent(DataViewHeaderToolsSearch), + viewOptions: createUniComponentFromWebComponent( + DataViewHeaderToolsViewOptions + ), + tableAddRow: createUniComponentFromWebComponent(DataViewHeaderToolsAddRow), +}; +export const createWidgetTools = ( + toolsMap: Record +) => { + return uniMap( + createUniComponentFromWebComponent(DataViewHeaderTools), + (props: DataViewWidgetProps) => ({ + ...props, + toolsMap, + }) + ); +}; diff --git a/packages/affine/microsheet-data-view/src/widget-presets/tools/presets/filter/filter.ts b/packages/affine/microsheet-data-view/src/widget-presets/tools/presets/filter/filter.ts new file mode 100644 index 000000000000..8e1e163c11e4 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/widget-presets/tools/presets/filter/filter.ts @@ -0,0 +1,116 @@ +import { popupTargetFromElement } from '@blocksuite/affine-components/context-menu'; +import { FilterIcon } from '@blocksuite/icons/lit'; +import { computed } from '@preact/signals-core'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { css, html, nothing } from 'lit'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { + emptyFilterGroup, + type FilterGroup, +} from '../../../../core/common/ast.js'; +import { popCreateFilter } from '../../../../core/common/ref/ref.js'; +import { WidgetBase } from '../../../../core/widget/widget-base.js'; +import { ShowFilterContextKey } from '../../../filter/context.js'; + +const styles = css` + .affine-microsheet-filter-button { + display: flex; + align-items: center; + gap: 6px; + line-height: 20px; + padding: 2px; + border-radius: 4px; + cursor: pointer; + font-size: 20px; + } + + .affine-microsheet-filter-button:hover { + background-color: var(--affine-hover-color); + } + + .affine-microsheet-filter-button { + } +`; + +export class DataViewHeaderToolsFilter extends WidgetBase { + static override styles = styles; + + hasFilter = computed(() => { + return this.view.filter$.value.conditions.length > 0; + }); + + private get _filter(): FilterGroup { + return this.view.filter$.value ?? emptyFilterGroup; + } + + private set _filter(filter: FilterGroup) { + this.view.filterSet(filter); + } + + private get readonly() { + return this.view.readonly$.value; + } + + private clickFilter(event: MouseEvent) { + if (this.hasFilter.value) { + this.toggleShowFilter(); + return; + } + this.showToolBar(true); + popCreateFilter( + popupTargetFromElement(event.currentTarget as HTMLElement), + { + vars: this.view.vars$, + onSelect: filter => { + this._filter = { + ...this._filter, + conditions: [filter], + }; + this.toggleShowFilter(true); + }, + onClose: () => { + this.showToolBar(false); + }, + } + ); + return; + } + + override render() { + if (this.readonly) return nothing; + const style = styleMap({ + color: this.hasFilter.value + ? cssVarV2('text/emphasis') + : cssVarV2('icon/primary'), + }); + return html`
+ ${FilterIcon()} +
`; + } + + showToolBar(show: boolean) { + const tools = this.closest('data-view-header-tools'); + if (tools) { + tools.showToolBar = show; + } + } + + toggleShowFilter(show?: boolean) { + const map = this.view.contextGet(ShowFilterContextKey); + map.value = { + ...map.value, + [this.view.id]: show ?? !map.value[this.view.id], + }; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'data-view-header-tools-filter': DataViewHeaderToolsFilter; + } +} diff --git a/packages/affine/microsheet-data-view/src/widget-presets/tools/presets/search/search.ts b/packages/affine/microsheet-data-view/src/widget-presets/tools/presets/search/search.ts new file mode 100644 index 000000000000..eceb112b62c0 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/widget-presets/tools/presets/search/search.ts @@ -0,0 +1,199 @@ +import { CloseIcon, SearchIcon } from '@blocksuite/icons/lit'; +import { baseTheme } from '@toeverything/theme'; +import { css, html, unsafeCSS } from 'lit'; +import { query, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import type { KanbanSingleView } from '../../../../view-presets/kanban/kanban-view-manager.js'; +import type { TableSingleView } from '../../../../view-presets/table/table-view-manager.js'; + +import { stopPropagation } from '../../../../core/utils/event.js'; +import { WidgetBase } from '../../../../core/widget/widget-base.js'; + +const styles = css` + .affine-microsheet-search-container { + position: relative; + display: flex; + align-items: center; + gap: 8px; + width: 24px; + height: 32px; + border-radius: 8px; + transition: width 0.3s ease; + overflow: hidden; + } + .affine-microsheet-search-container svg { + width: 20px; + height: 20px; + fill: var(--affine-icon-color); + } + + .search-container-expand { + overflow: visible; + width: 138px; + background-color: var(--affine-hover-color); + } + + .search-input-container { + display: flex; + align-items: center; + } + + .close-icon { + display: flex; + align-items: center; + padding-right: 8px; + height: 100%; + cursor: pointer; + } + + .affine-microsheet-search-input-icon { + position: absolute; + left: 0; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + cursor: pointer; + padding: 2px; + border-radius: 4px; + } + .affine-microsheet-search-input-icon:hover { + background: var(--affine-hover-color); + } + + .search-container-expand .affine-microsheet-search-input-icon { + left: 4px; + pointer-events: none; + } + + .affine-microsheet-search-input { + flex: 1; + width: 100%; + padding: 0 2px 0 30px; + border: none; + font-family: ${unsafeCSS(baseTheme.fontSansFamily)}; + font-size: var(--affine-font-sm); + box-sizing: border-box; + color: inherit; + background: transparent; + outline: none; + } + + .affine-microsheet-search-input::placeholder { + color: var(--affine-placeholder-color); + font-size: var(--affine-font-sm); + } +`; + +export class DataViewHeaderToolsSearch extends WidgetBase { + static override styles = styles; + + private _clearSearch = () => { + this._searchInput.value = ''; + this.view.setSearch(''); + this.preventBlur = true; + setTimeout(() => { + this.preventBlur = false; + }); + }; + + private _clickSearch = (e: MouseEvent) => { + e.stopPropagation(); + this.showSearch = true; + }; + + private _onSearch = (event: InputEvent) => { + const el = event.target as HTMLInputElement; + const inputValue = el.value.trim(); + this.view.setSearch(inputValue); + }; + + private _onSearchBlur = () => { + if (this._searchInput.value || this.preventBlur) { + return; + } + this.showSearch = false; + }; + + private _onSearchKeydown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + if (this._searchInput.value) { + this._searchInput.value = ''; + this.view.setSearch(''); + } else { + this.showSearch = false; + } + } + }; + + preventBlur = false; + + get showSearch(): boolean { + return this._showSearch; + } + + set showSearch(value: boolean) { + this._showSearch = value; + const tools = this.closest('data-view-header-tools'); + if (tools) { + tools.showToolBar = value; + } + } + + override render() { + const searchToolClassMap = classMap({ + 'affine-microsheet-search-container': true, + 'search-container-expand': this.showSearch, + }); + return html` + + `; + } + + @query('.affine-microsheet-search-input') + private accessor _searchInput!: HTMLInputElement; + + @state() + private accessor _showSearch = false; + + public override accessor view!: TableSingleView | KanbanSingleView; +} + +declare global { + interface HTMLElementTagNameMap { + 'data-view-header-tools-search': DataViewHeaderToolsSearch; + } +} diff --git a/packages/affine/microsheet-data-view/src/widget-presets/tools/presets/table-add-row/add-row.ts b/packages/affine/microsheet-data-view/src/widget-presets/tools/presets/table-add-row/add-row.ts new file mode 100644 index 000000000000..1627518fdc65 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/widget-presets/tools/presets/table-add-row/add-row.ts @@ -0,0 +1,214 @@ +import type { InsertToPosition } from '@blocksuite/affine-shared/utils'; + +import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import { PlusIcon } from '@blocksuite/icons/lit'; +import { css, html } from 'lit'; +import { state } from 'lit/decorators.js'; + +import { startDrag } from '../../../../core/utils/drag.js'; +import { WidgetBase } from '../../../../core/widget/widget-base.js'; +import { NewRecordPreview } from './new-record-preview.js'; + +const styles = css` + .affine-microsheet-toolbar-item.new-record { + margin-left: 12px; + display: flex; + align-items: center; + gap: 4px; + height: 32px; + padding: 4px 8px 4px 4px; + border-radius: 4px; + background: var(--affine-white); + cursor: grab; + font-size: 15px; + font-weight: 500; + line-height: 24px; + color: ${unsafeCSSVarV2('text/primary')}; + border: 1px solid ${unsafeCSSVarV2('layer/insideBorder/blackBorder')}; + } + + .new-record svg { + font-size: 20px; + color: ${unsafeCSSVarV2('icon/primary')}; + } +`; + +export class DataViewHeaderToolsAddRow extends WidgetBase { + static override styles = styles; + + private _onAddNewRecord = () => { + if (this.readonly) return; + const selection = this.viewMethods.getSelection?.(); + if (!selection) { + this.addRow('start'); + } else if ( + selection.type === 'table' && + selection.selectionType === 'area' + ) { + const { rowsSelection, columnsSelection, focus } = selection; + let index = 0; + if (rowsSelection && !columnsSelection) { + // rows + index = rowsSelection.end; + } else if (rowsSelection && columnsSelection) { + // multiple cells + index = rowsSelection.end; + } else if (!rowsSelection && !columnsSelection && focus) { + // single cell + index = focus.rowIndex; + } + + this.addRow(index + 1); + } + }; + + _dragStart = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + const container = this.closest('affine-microsheet-data-view-renderer'); + const tableRect = container + ?.querySelector('affine-microsheet-table') + ?.getBoundingClientRect(); + const rows: NodeListOf | undefined = + container?.querySelectorAll('.affine-microsheet-block-row'); + if (!rows || !tableRect) { + return; + } + const rects = Array.from(rows).map(v => { + const rect = v.getBoundingClientRect(); + return { + id: v.dataset.rowId as string, + top: rect.top, + bottom: rect.bottom, + mid: (rect.top + rect.bottom) / 2, + width: rect.width, + left: rect.left, + }; + }); + + const getPosition = ( + y: number + ): + | { position: InsertToPosition; y: number; x: number; width: number } + | undefined => { + const data = rects.find(v => y < v.bottom); + if (!data || y < data.top) { + return; + } + return { + position: { + id: data.id, + before: y < data.mid, + }, + y: y < data.mid ? data.top : data.bottom, + width: data.width, + x: data.left, + }; + }; + + const dropPreview = createDropPreview(); + const dragPreview = createDragPreview(); + startDrag<{ position?: InsertToPosition }, MouseEvent>(e, { + transform: e => e, + onDrag: () => { + return {}; + }, + onMove: e => { + dragPreview.display(e.x, e.y); + const p = getPosition(e.y); + if (p) { + dropPreview.display(tableRect.left, p.y, tableRect.width); + } else { + dropPreview.remove(); + } + return { + position: p?.position, + }; + }, + onDrop: data => { + if (data.position) { + this.viewMethods.addRow?.(data.position); + } + }, + onClear: () => { + dropPreview.remove(); + dragPreview.remove(); + }, + }); + }; + + addRow = (position: InsertToPosition | number) => { + this.viewMethods.addRow?.(position); + }; + + private get readonly() { + return this.view.readonly$.value; + } + + override connectedCallback() { + super.connectedCallback(); + if (!this.readonly) { + this.disposables.addFromEvent(this, 'pointerdown', e => { + this._dragStart(e); + }); + } + } + + override render() { + if (this.readonly) { + return; + } + return html`
+ ${PlusIcon()}New Record +
`; + } + + @state() + accessor showToolBar = false; +} + +declare global { + interface HTMLElementTagNameMap { + 'data-view-header-tools-add-row': DataViewHeaderToolsAddRow; + } +} +const createDropPreview = () => { + const div = document.createElement('div'); + div.dataset.isDropPreview = 'true'; + div.style.pointerEvents = 'none'; + div.style.position = 'fixed'; + div.style.zIndex = '9999'; + div.style.height = '4px'; + div.style.borderRadius = '2px'; + div.style.backgroundColor = 'var(--affine-primary-color)'; + div.style.boxShadow = '0px 0px 8px 0px rgba(30, 150, 235, 0.35)'; + return { + display(x: number, y: number, width: number) { + document.body.append(div); + div.style.left = `${x}px`; + div.style.top = `${y - 2}px`; + div.style.width = `${width}px`; + }, + remove() { + div.remove(); + }, + }; +}; + +const createDragPreview = () => { + const preview = new NewRecordPreview(); + document.body.append(preview); + return { + display(x: number, y: number) { + preview.style.left = `${x}px`; + preview.style.top = `${y}px`; + }, + remove() { + preview.remove(); + }, + }; +}; diff --git a/packages/affine/microsheet-data-view/src/widget-presets/tools/presets/table-add-row/new-record-preview.ts b/packages/affine/microsheet-data-view/src/widget-presets/tools/presets/table-add-row/new-record-preview.ts new file mode 100644 index 000000000000..2bc4a7990b46 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/widget-presets/tools/presets/table-add-row/new-record-preview.ts @@ -0,0 +1,43 @@ +import { ShadowlessElement } from '@blocksuite/block-std'; +import { PlusIcon } from '@blocksuite/icons/lit'; +import { html } from 'lit'; + +export class NewRecordPreview extends ShadowlessElement { + override render() { + return html` + + ${PlusIcon()} + `; + } +} diff --git a/packages/affine/microsheet-data-view/src/widget-presets/tools/presets/view-options/view-options.ts b/packages/affine/microsheet-data-view/src/widget-presets/tools/presets/view-options/view-options.ts new file mode 100644 index 000000000000..bbf0e6110f2e --- /dev/null +++ b/packages/affine/microsheet-data-view/src/widget-presets/tools/presets/view-options/view-options.ts @@ -0,0 +1,315 @@ +import { + menu, + type MenuButtonData, + type MenuConfig, + popMenu, + type PopupTarget, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { + ArrowRightSmallIcon, + DeleteIcon, + DuplicateIcon, + FilterIcon, + GroupingIcon, + InfoIcon, + LayoutIcon, + MoreHorizontalIcon, +} from '@blocksuite/icons/lit'; +import { css, html } from 'lit'; +import { styleMap } from 'lit/directives/style-map.js'; + +import type { SingleView } from '../../../../core/view-manager/single-view.js'; + +import { + popGroupSetting, + popSelectGroupByProperty, +} from '../../../../core/common/group-by/setting.js'; +import { popPropertiesSetting } from '../../../../core/common/properties.js'; +import { popCreateFilter } from '../../../../core/common/ref/ref.js'; +import { emptyFilterGroup, renderUniLit } from '../../../../core/index.js'; +import { WidgetBase } from '../../../../core/widget/widget-base.js'; +import { + KanbanSingleView, + type KanbanViewData, + TableSingleView, + type TableViewData, +} from '../../../../view-presets/index.js'; +import { popFilterRoot } from '../../../filter/filter-modal.js'; + +const styles = css` + .affine-microsheet-toolbar-item.more-action { + padding: 2px; + border-radius: 4px; + display: flex; + align-items: center; + cursor: pointer; + } + + .affine-microsheet-toolbar-item.more-action:hover { + background: var(--affine-hover-color); + } + + .affine-microsheet-toolbar-item.more-action svg { + width: 20px; + height: 20px; + fill: var(--affine-icon-color); + } + + .more-action.active { + background: var(--affine-hover-color); + } +`; + +export class DataViewHeaderToolsViewOptions extends WidgetBase { + static override styles = styles; + + clickMoreAction = (e: MouseEvent) => { + e.stopPropagation(); + this.openMoreAction(popupTargetFromElement(e.currentTarget as HTMLElement)); + }; + + openMoreAction = (target: PopupTarget) => { + this.showToolBar(true); + popViewOptions(target, this.view, () => { + this.showToolBar(false); + }); + }; + + override render() { + if (this.view.readonly$.value) { + return; + } + return html`
+ ${MoreHorizontalIcon()} +
`; + } + + showToolBar(show: boolean) { + const tools = this.closest('data-view-header-tools'); + if (tools) { + tools.showToolBar = show; + } + } + + override accessor view!: SingleView; +} + +declare global { + interface HTMLElementTagNameMap { + 'data-view-header-tools-view-options': DataViewHeaderToolsViewOptions; + } +} +export const popViewOptions = ( + target: PopupTarget, + view: SingleView, + onClose?: () => void +) => { + const reopen = () => { + popViewOptions(target, view); + }; + popMenu(target, { + options: { + title: { + text: 'View settings', + }, + items: [ + menu.input({ + initialValue: view.name$.value, + onComplete: text => { + view.nameSet(text); + }, + }), + menu.action({ + name: 'Layout', + postfix: html`
+ ${view.type} +
+ ${ArrowRightSmallIcon()}`, + select: () => { + const viewTypes = view.manager.viewMetas.map(meta => { + return menu => { + if (!menu.search(meta.model.defaultName)) { + return; + } + const isSelected = + meta.type === view.manager.currentView$.value.type; + const iconStyle = styleMap({ + fontSize: '24px', + color: isSelected + ? 'var(--affine-text-emphasis-color)' + : 'var(--affine-icon-secondary)', + }); + const textStyle = styleMap({ + fontSize: '14px', + lineHeight: '22px', + color: isSelected + ? 'var(--affine-text-emphasis-color)' + : 'var(--affine-text-secondary-color)', + }); + const data: MenuButtonData = { + content: () => html` +
+
+ ${renderUniLit(meta.renderer.icon)} +
+
${meta.model.defaultName}
+
+ `, + select: () => { + view.manager.viewChangeType( + view.manager.currentViewId$.value, + meta.type + ); + }, + class: '', + }; + const containerStyle = styleMap({ + flex: '1', + }); + return html` `; + }; + }); + popMenu(target, { + options: { + title: { + onBack: reopen, + text: 'Layout', + }, + items: [ + menu => { + const result = menu.renderItems(viewTypes); + if (result.length) { + return html`
${result}
`; + } + return html``; + }, + // menu.toggleSwitch({ + // name: 'Show block icon', + // on: true, + // onChange: value => { + // console.log(value); + // }, + // }), + // menu.toggleSwitch({ + // name: 'Show Vertical lines', + // on: true, + // onChange: value => { + // console.log(value); + // }, + // }), + ], + }, + }); + }, + prefix: LayoutIcon(), + }), + menu.group({ + items: [ + menu.action({ + name: 'Properties', + prefix: InfoIcon(), + postfix: html`
+ ${view.properties$.value.length} shown +
+ ${ArrowRightSmallIcon()}`, + select: () => { + popPropertiesSetting(target, { + view: view, + onBack: reopen, + }); + }, + }), + menu.action({ + name: 'Filter', + prefix: FilterIcon(), + postfix: html`
+ ${view.filter$.value.conditions.length + ? `${view.filter$.value.conditions.length} filters` + : ''} +
+ ${ArrowRightSmallIcon()}`, + select: () => { + if (!view.filter$.value.conditions.length) { + popCreateFilter(target, { + vars: view.vars$, + onBack: reopen, + onSelect: filter => { + console.log(filter, view.filter$.value); + view.filterSet({ + ...(view.filter$.value ?? emptyFilterGroup), + conditions: [...view.filter$.value.conditions, filter], + }); + popFilterRoot(target, { + view: view, + onBack: reopen, + }); + }, + }); + } else { + popFilterRoot(target, { + view: view, + onBack: reopen, + }); + } + }, + }), + menu.action({ + name: 'Group', + prefix: GroupingIcon(), + postfix: html`
+ ${view instanceof TableSingleView || + view instanceof KanbanSingleView + ? view.groupManager.property$.value?.name$.value + : ''} +
+ ${ArrowRightSmallIcon()}`, + select: () => { + const groupBy = view.data$.value?.groupBy; + if (!groupBy) { + popSelectGroupByProperty(target, view, { + onSelect: () => popGroupSetting(target, view, reopen), + onBack: reopen, + }); + } else { + popGroupSetting(target, view, reopen); + } + }, + }), + ], + }), + menu.group({ + items: [ + menu.action({ + name: 'Duplicate', + prefix: DuplicateIcon(), + select: () => { + view.duplicate(); + }, + }), + menu.action({ + name: 'Delete', + prefix: DeleteIcon(), + select: () => { + view.delete(); + }, + class: 'delete-item', + }), + ], + }), + ], + onClose: onClose, + }, + }); +}; diff --git a/packages/affine/microsheet-data-view/src/widget-presets/tools/tools-renderer.ts b/packages/affine/microsheet-data-view/src/widget-presets/tools/tools-renderer.ts new file mode 100644 index 000000000000..bc5086b50466 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/widget-presets/tools/tools-renderer.ts @@ -0,0 +1,85 @@ +import { css, html } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import type { SingleView } from '../../core/view-manager/single-view.js'; +import type { ViewManager } from '../../core/view-manager/view-manager.js'; +import type { + DataViewWidget, + DataViewWidgetProps, +} from '../../core/widget/types.js'; + +import { type DataViewExpose, renderUniLit } from '../../core/index.js'; +import { WidgetBase } from '../../core/widget/widget-base.js'; + +const styles = css` + .affine-microsheet-toolbar { + display: flex; + align-items: center; + gap: 6px; + visibility: hidden; + opacity: 0; + transition: opacity 150ms cubic-bezier(0.42, 0, 1, 1); + } + + .toolbar-hover-container:hover .affine-microsheet-toolbar { + visibility: visible; + opacity: 1; + } + + .show-toolbar { + visibility: visible; + opacity: 1; + } + + @media print { + .affine-microsheet-toolbar { + display: none; + } + } +`; + +export class DataViewHeaderTools extends WidgetBase { + static override styles = styles; + + override render() { + const classList = classMap({ + 'show-toolbar': this.showToolBar, + 'affine-microsheet-toolbar': true, + }); + const tools = this.toolsMap[this.view.type]; + return html`
+ ${repeat(tools ?? [], uni => { + const props: DataViewWidgetProps = { + view: this.view, + viewMethods: this.viewMethods, + }; + return renderUniLit(uni, props); + })} +
`; + } + + @state() + accessor showToolBar = false; + + @property({ attribute: false }) + accessor toolsMap!: Record; +} + +declare global { + interface HTMLElementTagNameMap { + 'data-view-header-tools': DataViewHeaderTools; + } +} +export const renderTools = ( + view: SingleView, + viewMethods: DataViewExpose, + viewSource: ViewManager +) => { + return html` `; +}; diff --git a/packages/affine/microsheet-data-view/src/widget-presets/views-bar/index.ts b/packages/affine/microsheet-data-view/src/widget-presets/views-bar/index.ts new file mode 100644 index 000000000000..ad1051349daf --- /dev/null +++ b/packages/affine/microsheet-data-view/src/widget-presets/views-bar/index.ts @@ -0,0 +1,5 @@ +import { createUniComponentFromWebComponent } from '../../core/index.js'; +import { DataViewHeaderViews } from './views.js'; + +export const widgetViewsBar = + createUniComponentFromWebComponent(DataViewHeaderViews); diff --git a/packages/affine/microsheet-data-view/src/widget-presets/views-bar/views.ts b/packages/affine/microsheet-data-view/src/widget-presets/views-bar/views.ts new file mode 100644 index 000000000000..9a9bf34a6dc8 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/widget-presets/views-bar/views.ts @@ -0,0 +1,297 @@ +import { + menu, + popFilterableSimpleMenu, + popMenu, + type PopupTarget, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { + AddCursorIcon, + DeleteIcon, + DuplicateIcon, + InfoIcon, + MoreHorizontalIcon, + MoveLeftIcon, + MoveRightIcon, +} from '@blocksuite/icons/lit'; +import { css, html } from 'lit'; +import { classMap } from 'lit/directives/class-map.js'; + +import { WidgetBase } from '../../core/widget/widget-base.js'; + +export class DataViewHeaderViews extends WidgetBase { + static override styles = css` + data-view-header-views { + height: 32px; + display: flex; + user-select: none; + gap: 4px; + } + data-view-header-views::-webkit-scrollbar-thumb { + width: 1px; + } + + .microsheet-view-button { + height: 100%; + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; + font-size: 14px; + display: flex; + align-items: center; + color: var(--affine-text-secondary-color); + white-space: nowrap; + } + + .microsheet-view-button .name { + align-items: center; + height: 22px; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + } + + .microsheet-view-button .icon { + margin-right: 6px; + display: block; + } + + .microsheet-view-button .icon svg { + width: 16px; + height: 16px; + } + + .microsheet-view-button.active { + color: var(--affine-text-primary-color); + background-color: var(--affine-hover-color-filled); + } + `; + + _addViewMenu = (event: MouseEvent) => { + popFilterableSimpleMenu( + popupTargetFromElement(event.currentTarget as HTMLElement), + this.dataSource.viewMetas.map(v => { + return menu.action({ + name: v.model.defaultName, + prefix: html``, + select: () => { + const id = this.viewManager.viewAdd(v.type); + this.viewManager.setCurrentView(id); + }, + }); + }) + ); + }; + + _showMore = (event: MouseEvent) => { + const views = this.viewManager.views$.value; + popFilterableSimpleMenu( + popupTargetFromElement(event.currentTarget as HTMLElement), + [ + ...views.map(id => { + const openViewOption = (event: MouseEvent) => { + event.stopPropagation(); + this.openViewOption( + popupTargetFromElement(event.currentTarget as HTMLElement), + id + ); + }; + const view = this.viewManager.viewGet(id); + return menu.action({ + prefix: html``, + name: view.data$.value?.name ?? '', + label: () => html`${view.data$.value?.name}`, + isSelected: this.viewManager.currentViewId$.value === id, + select: () => { + this.viewManager.setCurrentView(id); + }, + postfix: html`
+ ${MoreHorizontalIcon()} +
`, + }); + }), + menu.group({ + items: this.dataSource.viewMetas.map(v => { + return menu.action({ + name: `Create ${v.model.defaultName}`, + hide: () => this.readonly, + prefix: html``, + select: () => { + const id = this.viewManager.viewAdd(v.type); + this.viewManager.setCurrentView(id); + }, + }); + }), + }), + ] + ); + }; + + openViewOption = (target: PopupTarget, id: string) => { + if (this.readonly) { + return; + } + const views = this.viewManager.views$.value; + const index = views.findIndex(v => v === id); + const view = this.viewManager.viewGet(views[index]); + if (!view) { + return; + } + popMenu(target, { + options: { + items: [ + menu.input({ + initialValue: view.data$.value?.name, + onComplete: text => { + view.dataUpdate(_data => ({ + name: text, + })); + }, + }), + menu.action({ + name: 'Edit View', + prefix: InfoIcon(), + select: () => { + this.closest('affine-microsheet-data-view-renderer') + ?.querySelector('data-view-header-tools-view-options') + ?.openMoreAction(target); + }, + }), + menu.action({ + name: 'Move Left', + hide: () => index === 0, + prefix: MoveLeftIcon(), + select: () => { + const targetId = views[index - 1]; + this.viewManager.moveTo( + id, + targetId ? { before: true, id: targetId } : 'start' + ); + }, + }), + menu.action({ + name: 'Move Right', + prefix: MoveRightIcon(), + hide: () => index === views.length - 1, + select: () => { + const targetId = views[index + 1]; + this.viewManager.moveTo( + id, + targetId ? { before: false, id: targetId } : 'end' + ); + }, + }), + menu.group({ + items: [ + menu.action({ + name: 'Duplicate', + prefix: DuplicateIcon(), + select: () => { + this.viewManager.viewDuplicate(id); + }, + }), + menu.action({ + name: 'Delete', + prefix: DeleteIcon(), + select: () => { + view.delete(); + }, + class: 'delete-item', + }), + ], + }), + ], + }, + }); + }; + + renderMore = (count: number) => { + const views = this.viewManager.views$.value; + if (count === views.length) { + if (this.readonly) { + return; + } + return html`
+ ${AddCursorIcon()} +
`; + } + return html` +
+ ${views.length - count} More +
+ `; + }; + + renderViews = () => { + const views = this.viewManager.views$.value; + return views.map(id => () => { + const classList = classMap({ + 'microsheet-view-button': true, + 'dv-hover': true, + active: this.viewManager.currentViewId$.value === id, + }); + const view = this.viewManager.viewDataGet(id); + return html` +
+ +
${view?.name}
+
+ `; + }); + }; + + get readonly() { + return this.viewManager.readonly$.value; + } + + private getRenderer(viewId: string) { + return this.dataSource.viewMetaGetById(viewId).renderer; + } + + _clickView(event: MouseEvent, id: string) { + if (this.viewManager.currentViewId$.value !== id) { + this.viewManager.setCurrentView(id); + return; + } + this.openViewOption( + popupTargetFromElement(event.currentTarget as HTMLElement), + id + ); + } + + override render() { + return html` + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'data-view-header-views': DataViewHeaderViews; + } +} diff --git a/packages/affine/microsheet-data-view/tsconfig.json b/packages/affine/microsheet-data-view/tsconfig.json new file mode 100644 index 000000000000..36f0b6ac4da7 --- /dev/null +++ b/packages/affine/microsheet-data-view/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src/", + "outDir": "./dist/", + "noEmit": false + }, + "include": ["./src"], + "references": [ + { + "path": "../components" + }, + { + "path": "../shared" + }, + { + "path": "../../framework/block-std" + }, + { + "path": "../../framework/global" + }, + { + "path": "../../framework/store" + } + ] +} diff --git a/packages/affine/microsheet-data-view/typedoc.json b/packages/affine/microsheet-data-view/typedoc.json new file mode 100644 index 000000000000..101e923dbadb --- /dev/null +++ b/packages/affine/microsheet-data-view/typedoc.json @@ -0,0 +1,4 @@ +{ + "extends": ["../../../typedoc.base.json"], + "entryPoints": ["src/index.ts"] +} diff --git a/packages/affine/microsheet-data-view/vitest.config.ts b/packages/affine/microsheet-data-view/vitest.config.ts new file mode 100644 index 000000000000..1e76565bf5f7 --- /dev/null +++ b/packages/affine/microsheet-data-view/vitest.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + esbuild: { + target: 'es2018', + }, + test: { + globalSetup: '../../scripts/vitest-global.ts', + include: ['src/__tests__/**/*.unit.spec.ts'], + testTimeout: 1000, + coverage: { + provider: 'istanbul', // or 'c8' + reporter: ['lcov'], + reportsDirectory: '../../.coverage/blocks', + }, + /** + * Custom handler for console.log in tests. + * + * Return `false` to ignore the log. + */ + onConsoleLog(log, type) { + if (log.includes('https://lit.dev/msg/dev-mode')) { + return false; + } + console.warn(`Unexpected ${type} log`, log); + throw new Error(log); + }, + environment: 'happy-dom', + }, +}); diff --git a/packages/affine/model/src/blocks/cell/cell-model.ts b/packages/affine/model/src/blocks/cell/cell-model.ts new file mode 100644 index 000000000000..3714eedb2ee0 --- /dev/null +++ b/packages/affine/model/src/blocks/cell/cell-model.ts @@ -0,0 +1,31 @@ +import { defineBlockSchema, type SchemaToModel } from '@blocksuite/store'; + +export const CellBlockSchema = defineBlockSchema({ + flavour: 'affine:cell', + metadata: { + version: 1, + role: 'hub', + parent: ['affine:row'], + children: [ + 'affine:paragraph', + 'affine:list', + 'affine:code', + 'affine:divider', + 'affine:image', + 'affine:bookmark', + 'affine:attachment', + 'affine:surface-ref', + 'affine:embed-*', + ], + }, +}); + +export type CellBlockModel = SchemaToModel; + +declare global { + namespace BlockSuite { + interface BlockModels { + 'affine:cell': CellBlockModel; + } + } +} diff --git a/packages/affine/model/src/blocks/cell/index.ts b/packages/affine/model/src/blocks/cell/index.ts new file mode 100644 index 000000000000..6f2a1ac6e550 --- /dev/null +++ b/packages/affine/model/src/blocks/cell/index.ts @@ -0,0 +1 @@ +export * from './cell-model.js'; diff --git a/packages/affine/model/src/blocks/index.ts b/packages/affine/model/src/blocks/index.ts index 922e44a2b1e6..7b8e0c107d00 100644 --- a/packages/affine/model/src/blocks/index.ts +++ b/packages/affine/model/src/blocks/index.ts @@ -1,5 +1,6 @@ export * from './attachment/index.js'; export * from './bookmark/index.js'; +export * from './cell/index.js'; export * from './code/index.js'; export * from './database/index.js'; export * from './divider/index.js'; @@ -13,4 +14,5 @@ export * from './microsheet/index.js'; export * from './note/index.js'; export * from './paragraph/index.js'; export * from './root/index.js'; +export * from './row/index.js'; export * from './surface-ref/index.js'; diff --git a/packages/affine/model/src/blocks/microsheet/microsheet-model.ts b/packages/affine/model/src/blocks/microsheet/microsheet-model.ts index 4852cdd72b22..a03aea7c47f7 100644 --- a/packages/affine/model/src/blocks/microsheet/microsheet-model.ts +++ b/packages/affine/model/src/blocks/microsheet/microsheet-model.ts @@ -27,7 +27,7 @@ export const MicrosheetBlockSchema = defineBlockSchema({ role: 'hub', version: 3, parent: ['affine:note'], - children: [], + children: ['affine:row'], }, toModel: () => new MicrosheetBlockModel(), }); diff --git a/packages/affine/model/src/blocks/paragraph/paragraph-model.ts b/packages/affine/model/src/blocks/paragraph/paragraph-model.ts index 0fb20dfb8cd7..50efbf793efe 100644 --- a/packages/affine/model/src/blocks/paragraph/paragraph-model.ts +++ b/packages/affine/model/src/blocks/paragraph/paragraph-model.ts @@ -34,6 +34,7 @@ export const ParagraphBlockSchema = defineBlockSchema({ 'affine:paragraph', 'affine:list', 'affine:edgeless-text', + 'affine:cell', ], }, }); diff --git a/packages/affine/model/src/blocks/row/index.ts b/packages/affine/model/src/blocks/row/index.ts new file mode 100644 index 000000000000..8f67cb41153b --- /dev/null +++ b/packages/affine/model/src/blocks/row/index.ts @@ -0,0 +1 @@ +export * from './row-model.js'; diff --git a/packages/affine/model/src/blocks/row/row-model.ts b/packages/affine/model/src/blocks/row/row-model.ts new file mode 100644 index 000000000000..d32db7770324 --- /dev/null +++ b/packages/affine/model/src/blocks/row/row-model.ts @@ -0,0 +1,21 @@ +import { defineBlockSchema, type SchemaToModel } from '@blocksuite/store'; + +export const RowBlockSchema = defineBlockSchema({ + flavour: 'affine:row', + metadata: { + version: 1, + role: 'hub', + parent: ['affine:microsheet'], + children: ['affine:cell'], + }, +}); + +export type RowBlockModel = SchemaToModel; + +declare global { + namespace BlockSuite { + interface BlockModels { + 'affine:row': RowBlockModel; + } + } +} diff --git a/packages/blocks/package.json b/packages/blocks/package.json index cd9e0a990028..16ccdf4cdc79 100644 --- a/packages/blocks/package.json +++ b/packages/blocks/package.json @@ -33,6 +33,7 @@ "@blocksuite/global": "workspace:*", "@blocksuite/icons": "^2.1.68", "@blocksuite/inline": "workspace:*", + "@blocksuite/microsheet-data-view": "workspace:*", "@blocksuite/store": "workspace:*", "@floating-ui/dom": "^1.6.10", "@lit/context": "^1.1.2", diff --git a/packages/blocks/src/_specs/common.ts b/packages/blocks/src/_specs/common.ts index 669ac04a490f..71f22f5173d2 100644 --- a/packages/blocks/src/_specs/common.ts +++ b/packages/blocks/src/_specs/common.ts @@ -8,6 +8,7 @@ import { EditPropsStore } from '@blocksuite/affine-shared/services'; import { AttachmentBlockSpec } from '../attachment-block/attachment-spec.js'; import { BookmarkBlockSpec } from '../bookmark-block/bookmark-spec.js'; +import { CellBlockSpec } from '../cell-block/cell-spec.js'; import { CodeBlockSpec } from '../code-block/code-block-spec.js'; import { DataViewBlockSpec } from '../data-view-block/data-view-spec.js'; import { DatabaseBlockSpec } from '../database-block/database-spec.js'; @@ -18,6 +19,7 @@ import { EdgelessNoteBlockSpec, NoteBlockSpec, } from '../note-block/note-spec.js'; +import { RowBlockSpec } from '../row-block/row-spec.js'; export const CommonFirstPartyBlockSpecs: ExtensionType[] = [ RichTextExtensions, @@ -26,6 +28,8 @@ export const CommonFirstPartyBlockSpecs: ExtensionType[] = [ NoteBlockSpec, DatabaseBlockSpec, MicrosheetBlockSpec, + RowBlockSpec, + CellBlockSpec, DataViewBlockSpec, DividerBlockSpec, CodeBlockSpec, @@ -43,6 +47,8 @@ export const EdgelessFirstPartyBlockSpecs: ExtensionType[] = [ EdgelessNoteBlockSpec, DatabaseBlockSpec, MicrosheetBlockSpec, + RowBlockSpec, + CellBlockSpec, DataViewBlockSpec, DividerBlockSpec, CodeBlockSpec, diff --git a/packages/blocks/src/cell-block/cell-block.ts b/packages/blocks/src/cell-block/cell-block.ts new file mode 100644 index 000000000000..7ee7bdf201e0 --- /dev/null +++ b/packages/blocks/src/cell-block/cell-block.ts @@ -0,0 +1,37 @@ +/// + +import type { CellBlockModel } from '@blocksuite/affine-model'; + +import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption'; +import { html } from 'lit'; + +import type { CellBlockService } from './cell-service.js'; + +import { KeymapController } from './keymap-controller.js'; +import { cellBlockStyles } from './styles.js'; + +export class CellBlockComponent extends CaptionedBlockComponent< + CellBlockModel, + CellBlockService +> { + static override styles = cellBlockStyles; + + keymapController = new KeymapController(this); + + override connectedCallback() { + super.connectedCallback(); + + this.keymapController.bind(); + } + + override renderBlock() { + console.log('renderCell'); + return html`${this.renderChildren(this.model)}`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-cell': CellBlockComponent; + } +} diff --git a/packages/blocks/src/cell-block/cell-service.ts b/packages/blocks/src/cell-block/cell-service.ts new file mode 100644 index 000000000000..ca1d72bf95ed --- /dev/null +++ b/packages/blocks/src/cell-block/cell-service.ts @@ -0,0 +1,35 @@ +import { CellBlockSchema } from '@blocksuite/affine-model'; +import { BlockService, type Command } from '@blocksuite/block-std'; + +export class CellBlockService extends BlockService { + static override readonly flavour = CellBlockSchema.model.flavour; + + override mounted(): void { + super.mounted(); + + this.std.command.add('selectBlock', selectBlock); + } +} + +export const selectBlock: Command<'focusBlock'> = (ctx, next) => { + const { focusBlock } = ctx; + if (!focusBlock) { + return; + } + + // const { selection } = std; + + // selection.setGroup('cell', [ + // selection.create('block', { path: focusBlock.path }), + // ]); + + return next(); +}; + +declare global { + namespace BlockSuite { + interface Commands { + selectBlock: typeof selectBlock; + } + } +} diff --git a/packages/blocks/src/cell-block/cell-spec.ts b/packages/blocks/src/cell-block/cell-spec.ts new file mode 100644 index 000000000000..ee725f704623 --- /dev/null +++ b/packages/blocks/src/cell-block/cell-spec.ts @@ -0,0 +1,14 @@ +import { + BlockViewExtension, + type ExtensionType, + FlavourExtension, +} from '@blocksuite/block-std'; +import { literal } from 'lit/static-html.js'; + +import { CellBlockService } from './cell-service.js'; + +export const CellBlockSpec: ExtensionType[] = [ + FlavourExtension('affine:cell'), + CellBlockService, + BlockViewExtension('affine:cell', literal`affine-cell`), +]; diff --git a/packages/blocks/src/cell-block/index.ts b/packages/blocks/src/cell-block/index.ts new file mode 100644 index 000000000000..70919afb5da5 --- /dev/null +++ b/packages/blocks/src/cell-block/index.ts @@ -0,0 +1,16 @@ +import type { CellBlockModel } from '@blocksuite/affine-model'; + +import type { CellBlockService } from './cell-service.js'; + +export * from './cell-block.js'; +export * from './cell-service.js'; +declare global { + namespace BlockSuite { + interface BlockServices { + 'affine:cell': CellBlockService; + } + interface BlockModels { + 'affine:cell': CellBlockModel; + } + } +} diff --git a/packages/blocks/src/cell-block/keymap-controller.ts b/packages/blocks/src/cell-block/keymap-controller.ts new file mode 100644 index 000000000000..df5ab7b9f18b --- /dev/null +++ b/packages/blocks/src/cell-block/keymap-controller.ts @@ -0,0 +1,352 @@ +/* eslint-disable */ +import type { BlockSelection, UIEventHandler } from '@blocksuite/block-std'; +import type { BlockElement } from '@blocksuite/lit'; +import type { ReactiveController } from 'lit'; + +import { assertExists } from '@blocksuite/global/utils'; +import type { ReactiveControllerHost } from 'lit'; + +export const ensureBlockInContainer = ( + blockElement: BlockElement, + containerElement: BlockElement +) => + containerElement.contains(blockElement) && blockElement !== containerElement; + +export class KeymapController implements ReactiveController { + private _anchorSel: BlockSelection | null = null; + private _focusBlock: BlockElement | null = null; + + host: ReactiveControllerHost & BlockElement; + + private get _std() { + return this.host.std; + } + + constructor(host: ReactiveControllerHost & BlockElement) { + (this.host = host).addController(this); + } + + hostConnected() { + this._reset(); + } + + hostDisconnected() { + this._reset(); + } + + private _reset = () => { + this._anchorSel = null; + this._focusBlock = null; + }; + + bind = () => { + this.host.handleEvent('keyDown', ctx => { + const state = ctx.get('keyboardState'); + if (state.raw.key === 'Shift') { + return; + } + this._reset(); + }); + + this.host.bindHotKey({ + // ArrowDown: this._onArrowDown, + // ArrowUp: this._onArrowUp, + // 'Shift-ArrowDown': this._onShiftArrowDown, + // 'Shift-ArrowUp': this._onShiftArrowUp, + // Escape: this._onEsc, + Enter: this._onEnter, + 'Mod-a': this._onSelectAll, + }); + + // this._bindQuickActionHotKey(); + // this._bindTextConversionHotKey(); + // this._bindMoveBlockHotKey(); + }; + + private _onArrowDown = () => { + const [result] = this._std.command + .pipe() + .inline((_, next) => { + this._reset(); + return next(); + }) + .try(cmd => [ + // block selection - select the next block + this._onBlockDown(cmd), + ]) + .run(); + + return result; + }; + + private _onBlockDown = (cmd: BlockSuite.CommandChain) => { + return cmd + .getBlockSelections() + .inline<'currentSelectionPath'>((ctx, next) => { + const currentBlockSelections = ctx.currentBlockSelections; + assertExists(currentBlockSelections); + const blockSelection = currentBlockSelections.at(-1); + if (!blockSelection) { + return; + } + return next({ currentSelectionPath: blockSelection.path }); + }) + .getNextBlock() + .inline<'focusBlock'>((ctx, next) => { + const { nextBlock } = ctx; + assertExists(nextBlock); + + if (!ensureBlockInContainer(nextBlock, this.host)) { + return; + } + + return next({ + focusBlock: nextBlock, + }); + }) + .selectBlock(); + }; + + private _onArrowUp = () => { + const [result] = this._std.command + .pipe() + .inline((_, next) => { + this._reset(); + return next(); + }) + .try(cmd => [ + // block selection - select the previous block + this._onBlockUp(cmd), + ]) + .run(); + + return result; + }; + + private _onBlockUp = (cmd: BlockSuite.CommandChain) => { + return cmd + .getBlockSelections() + .inline<'currentSelectionPath'>((ctx, next) => { + const currentBlockSelections = ctx.currentBlockSelections; + assertExists(currentBlockSelections); + const blockSelection = currentBlockSelections.at(0); + if (!blockSelection) { + return; + } + return next({ currentSelectionPath: blockSelection.path }); + }) + .getPrevBlock() + .inline((ctx, next) => { + const { prevBlock } = ctx; + assertExists(prevBlock); + + if (!ensureBlockInContainer(prevBlock, this.host)) { + return; + } + + return next({ + focusBlock: prevBlock, + }); + }) + .selectBlock(); + }; + + private _onShiftArrowDown = () => { + const [result] = this._std.command + .pipe() + .try(cmd => [ + // block selection + this._onBlockShiftDown(cmd), + ]) + .run(); + + return result; + }; + + private _onBlockShiftDown = (cmd: BlockSuite.CommandChain) => { + return cmd + .getBlockSelections() + .inline<'currentSelectionPath' | 'anchorBlock'>((ctx, next) => { + const blockSelections = ctx.currentBlockSelections; + assertExists(blockSelections); + if (!this._anchorSel) { + this._anchorSel = blockSelections.at(-1) ?? null; + } + if (!this._anchorSel) { + return; + } + + const anchorBlock = ctx.std.view.viewFromPath( + 'block', + this._anchorSel.path + ); + if (!anchorBlock) { + return; + } + return next({ + anchorBlock, + currentSelectionPath: this._focusBlock?.path ?? anchorBlock?.path, + }); + }) + .getNextBlock({}) + .inline<'focusBlock'>((ctx, next) => { + assertExists(ctx.nextBlock); + this._focusBlock = ctx.nextBlock; + if (!ensureBlockInContainer(this._focusBlock, this.host)) { + return; + } + return next({ + focusBlock: this._focusBlock, + }); + }) + .selectBlocksBetween({ tail: true }); + }; + + private _onShiftArrowUp = () => { + const [result] = this._std.command + .pipe() + .try(cmd => [ + // block selection + this._onBlockShiftUp(cmd), + ]) + .run(); + + return result; + }; + + private _onBlockShiftUp = (cmd: BlockSuite.CommandChain) => { + return cmd + .getBlockSelections() + .inline<'currentSelectionPath' | 'anchorBlock'>((ctx, next) => { + const blockSelections = ctx.currentBlockSelections; + assertExists(blockSelections); + if (!this._anchorSel) { + this._anchorSel = blockSelections.at(0) ?? null; + } + if (!this._anchorSel) { + return; + } + const anchorBlock = ctx.std.view.viewFromPath( + 'block', + this._anchorSel.path + ); + if (!anchorBlock) { + return; + } + return next({ + anchorBlock, + currentSelectionPath: this._focusBlock?.path ?? anchorBlock?.path, + }); + }) + .getPrevBlock({}) + .inline((ctx, next) => { + assertExists(ctx.prevBlock); + this._focusBlock = ctx.prevBlock; + if (!ensureBlockInContainer(this._focusBlock, this.host)) { + return; + } + return next({ + focusBlock: this._focusBlock, + }); + }) + .selectBlocksBetween({ tail: false }); + }; + + private _onEsc = () => { + const [result] = this._std.command + .pipe() + .getBlockSelections() + .inline((ctx, next) => { + const blockSelection = ctx.currentBlockSelections?.at(-1); + if (!blockSelection) { + return; + } + + ctx.std.selection.update(selList => { + return selList.filter(sel => !sel.is('block')); + }); + + return next(); + }) + .run(); + + return result; + }; + + private _onEnter = () => { + const [result] = this._std.command + .pipe() + .getBlockSelections() + .inline((ctx, next) => { + const blockSelection = ctx.currentBlockSelections?.at(-1); + if (!blockSelection) { + return; + } + + const { view, page, selection } = ctx.std; + + const element = view.viewFromPath('block', blockSelection.path); + if (!element) { + return; + } + + const { model } = element; + const parent = page.getParent(model); + console.log(model, parent); + if (!parent) { + return; + } + + const index = parent.children.indexOf(model) ?? undefined; + + const blockId = page.addBlock( + 'affine:paragraph', + {}, + parent, + index + 1 + ); + + const sel = selection.create('text', { + from: { + path: element.parentPath.concat(blockId), + index: 0, + length: 0, + }, + to: null, + }); + + selection.setGroup('cell', [sel]); + + return next(); + }) + .run(); + + return result; + }; + + private _onSelectAll: UIEventHandler = ctx => { + ctx.get('defaultState').event.preventDefault(); + const selection = this._std.selection; + if (!selection.find('block')) { + return; + } + + try { + if (selection.value.length === this.host.model.children.length) return; + } catch (err) { + console.log(err); + } + const blocks: BlockSelection[] = this.host.model.children.map(child => { + return selection.create('block', { + blockId: child.id, + }); + }); + selection.update(selList => { + return selList + .filter(sel => !sel.is('block')) + .concat(blocks); + }); + + return true; + }; +} diff --git a/packages/blocks/src/cell-block/styles.ts b/packages/blocks/src/cell-block/styles.ts new file mode 100644 index 000000000000..797e0d2f2019 --- /dev/null +++ b/packages/blocks/src/cell-block/styles.ts @@ -0,0 +1,13 @@ +import { css } from 'lit'; + +export const cellBlockStyles = css` + affine-cell { + width: 100%; + } + .affine-cell-block-container { + display: flow-root; + } + .affine-cell-block-container.selected { + background-color: var(--affine-hover-color); + } +`; diff --git a/packages/blocks/src/effects.ts b/packages/blocks/src/effects.ts index a372ba3b9ffa..5df147b752c2 100644 --- a/packages/blocks/src/effects.ts +++ b/packages/blocks/src/effects.ts @@ -16,14 +16,10 @@ import { effects as widgetScrollAnchoringEffects } from '@blocksuite/affine-widg import { effects as stdEffects } from '@blocksuite/block-std/effects'; import { effects as dataViewEffects } from '@blocksuite/data-view/effects'; import { effects as inlineEffects } from '@blocksuite/inline/effects'; +import { effects as microsheetDataViewEffects } from '@blocksuite/microsheet-data-view/effects'; import type { insertBookmarkCommand } from './bookmark-block/commands/insert-bookmark.js'; import type { insertEdgelessTextCommand } from './edgeless-text-block/commands/insert-edgeless-text.js'; -import type { - MicrosheetBlockComponent, - MicrosheetBlockService, - type MicrosheetBlockService, -} from './microsheet-block/index.js'; import type { updateBlockType } from './note-block/commands/block-type.js'; import type { dedentBlock } from './note-block/commands/dedent-block.js'; import type { dedentBlockToRoot } from './note-block/commands/dedent-block-to-root.js'; @@ -66,6 +62,10 @@ import { BookmarkBlockComponent, type BookmarkBlockService, } from './bookmark-block/index.js'; +import { + CellBlockComponent, + type CellBlockService, +} from './cell-block/index.js'; import { AffineCodeUnit } from './code-block/highlight/affine-code-unit.js'; import { CodeBlockComponent, @@ -111,6 +111,10 @@ import { } from './image-block/index.js'; import { effects as blockLatexEffects } from './latex-block/effects.js'; import { LatexBlockComponent } from './latex-block/index.js'; +import { + MicrosheetBlockComponent, + type MicrosheetBlockService, +} from './microsheet-block/index.js'; import { EdgelessNoteBlockComponent, EdgelessNoteMask, @@ -306,6 +310,7 @@ import { AFFINE_VIEWPORT_OVERLAY_WIDGET, AffineViewportOverlayWidget, } from './root-block/widgets/viewport-overlay/viewport-overlay.js'; +import { RowBlockComponent, type RowBlockService } from './row-block/index.js'; import { MindmapRootBlock, MindmapSurfaceBlock, @@ -335,6 +340,7 @@ export function effects() { blockDatabaseEffects(); blockSurfaceRefEffects(); blockLatexEffects(); + microsheetDataViewEffects(); componentCaptionEffects(); componentContextMenuEffects(); @@ -416,6 +422,8 @@ export function effects() { customElements.define('affine-custom-modal', AffineCustomModal); customElements.define('affine-database', DatabaseBlockComponent); customElements.define('affine-microsheet', MicrosheetBlockComponent); + customElements.define('affine-row', RowBlockComponent); + customElements.define('affine-cell', CellBlockComponent); customElements.define('affine-surface-ref', SurfaceRefBlockComponent); customElements.define('pie-node-child', PieNodeChild); customElements.define('pie-node-content', PieNodeContent); @@ -728,6 +736,8 @@ declare global { 'affine:bookmark': BookmarkBlockService; 'affine:database': DatabaseBlockService; 'affine:microsheet': MicrosheetBlockService; + 'affine:row': RowBlockService; + 'affine:cell': CellBlockService; 'affine:image': ImageBlockService; 'affine:surface-ref': SurfaceRefBlockService; } diff --git a/packages/blocks/src/index.ts b/packages/blocks/src/index.ts index 9e89f1a3f534..7631b47d75d2 100644 --- a/packages/blocks/src/index.ts +++ b/packages/blocks/src/index.ts @@ -21,6 +21,7 @@ export { type AbstractEditor } from './_common/types.js'; export * from './_specs/index.js'; export * from './attachment-block/index.js'; export * from './bookmark-block/index.js'; +export * from './cell-block/index.js'; export * from './code-block/index.js'; export * from './data-view-block/index.js'; export * from './database-block/index.js'; @@ -30,6 +31,7 @@ export * from './frame-block/index.js'; export * from './image-block/index.js'; export * from './latex-block/index.js'; export * from './microsheet-block/index.js'; +export * from './microsheet-data-view-block/index.js'; export * from './note-block/index.js'; export { EdgelessTemplatePanel } from './root-block/edgeless/components/toolbar/template/template-panel.js'; export type { @@ -48,6 +50,7 @@ export { EditPropsMiddlewareBuilder } from './root-block/edgeless/middlewares/ba export * from './root-block/edgeless/utils/common.js'; export { EdgelessSnapManager } from './root-block/edgeless/utils/snap-manager.js'; export * from './root-block/index.js'; +export * from './row-block/index.js'; export * from './schemas.js'; export { markdownToMindmap, diff --git a/packages/blocks/src/microsheet-block/context/host-context.ts b/packages/blocks/src/microsheet-block/context/host-context.ts index 2b321b18d543..5b4a62dd7587 100644 --- a/packages/blocks/src/microsheet-block/context/host-context.ts +++ b/packages/blocks/src/microsheet-block/context/host-context.ts @@ -1,6 +1,6 @@ import type { EditorHost } from '@blocksuite/block-std'; -import { createContextKey } from '@blocksuite/data-view'; +import { createContextKey } from '@blocksuite/microsheet-data-view'; export const HostContextKey = createContextKey( 'editor-host', diff --git a/packages/blocks/src/microsheet-block/data-source.ts b/packages/blocks/src/microsheet-block/data-source.ts index c6f14c4b61a1..0b40ccd72943 100644 --- a/packages/blocks/src/microsheet-block/data-source.ts +++ b/packages/blocks/src/microsheet-block/data-source.ts @@ -5,20 +5,19 @@ import { insertPositionToIndex, type InsertToPosition, } from '@blocksuite/affine-shared/utils'; +import { assertExists } from '@blocksuite/global/utils'; import { DataSourceBase, type DataViewDataType, - getTagColor, type MicrosheetFlags, type PropertyMetaConfig, type TType, type ViewManager, ViewManagerBase, type ViewMeta, -} from '@blocksuite/data-view'; -import { propertyPresets } from '@blocksuite/data-view/property-presets'; -import { assertExists } from '@blocksuite/global/utils'; -import { type BlockModel, nanoid, Text } from '@blocksuite/store'; +} from '@blocksuite/microsheet-data-view'; +import { propertyPresets } from '@blocksuite/microsheet-data-view/property-presets'; +import { type BlockModel, Text } from '@blocksuite/store'; import { computed, type ReadonlySignal } from '@preact/signals-core'; import { getIcon } from './block-icons.js'; @@ -128,6 +127,10 @@ export class MicrosheetBlockDataSource extends DataSourceBase { return `Column ${i}`; } + cellRefGet(rowId: string, propertyId: string): unknown { + return getCell(this._model, rowId, propertyId)?.ref; + } + cellValueChange(rowId: string, propertyId: string, value: unknown): void { this._runCapture(); @@ -413,42 +416,33 @@ export const microsheetViewInitTemplate = ( model: MicrosheetBlockModel, viewType: string ) => { - const ids = [nanoid(), nanoid(), nanoid()]; - const statusId = addProperty( - model, - 'end', - propertyPresets.selectPropertyConfig.create('Status', { - options: [ - { - id: ids[0], - color: getTagColor(), - value: 'TODO', - }, - { - id: ids[1], - color: getTagColor(), - value: 'In Progress', - }, + const columnIds = []; + for (let u = 0; u < 3; u++) { + columnIds.push( + addProperty( + model, + 'end', + propertyPresets.textPropertyConfig.create('', {}) + ) + ); + } + for (let i = 0; i < 2; i++) { + const rowId = model.doc.addBlock('affine:row', {}, model.id); + for (let u = 0; u < 3; u++) { + const cellId = model.doc.addBlock('affine:cell', {}, rowId); + model.doc.addBlock( + 'affine:paragraph', { - id: ids[2], - color: getTagColor(), - value: 'Done', + text: new model.doc.Text(`Cell...`), }, - ], - }) - ); - for (let i = 0; i < 4; i++) { - const rowId = model.doc.addBlock( - 'affine:paragraph', - { - text: new model.doc.Text(`Task ${i + 1}`), - }, - model.id - ); - updateCell(model, rowId, { - columnId: statusId, - value: ids[i], - }); + cellId + ); + updateCell(model, rowId, { + columnId: columnIds[u], + value: '', + ref: cellId, + }); + } } microsheetViewInitEmpty(model, viewType); }; diff --git a/packages/blocks/src/microsheet-block/detail-panel/block-renderer.ts b/packages/blocks/src/microsheet-block/detail-panel/block-renderer.ts index d77b4caea811..742cab336791 100644 --- a/packages/blocks/src/microsheet-block/detail-panel/block-renderer.ts +++ b/packages/blocks/src/microsheet-block/detail-panel/block-renderer.ts @@ -1,9 +1,9 @@ import type { EditorHost } from '@blocksuite/block-std'; -import type { DetailSlotProps } from '@blocksuite/data-view'; +import type { DetailSlotProps } from '@blocksuite/microsheet-data-view'; import type { KanbanSingleView, TableSingleView, -} from '@blocksuite/data-view/view-presets'; +} from '@blocksuite/microsheet-data-view/view-presets'; import { DefaultInlineManagerExtension } from '@blocksuite/affine-components/rich-text'; import { ShadowlessElement } from '@blocksuite/block-std'; diff --git a/packages/blocks/src/microsheet-block/detail-panel/note-renderer.ts b/packages/blocks/src/microsheet-block/detail-panel/note-renderer.ts index ff505066c70d..65b6ff767e1a 100644 --- a/packages/blocks/src/microsheet-block/detail-panel/note-renderer.ts +++ b/packages/blocks/src/microsheet-block/detail-panel/note-renderer.ts @@ -1,5 +1,8 @@ import type { MicrosheetBlockModel } from '@blocksuite/affine-model'; -import type { DetailSlotProps, SingleView } from '@blocksuite/data-view'; +import type { + DetailSlotProps, + SingleView, +} from '@blocksuite/microsheet-data-view'; import { focusTextModel } from '@blocksuite/affine-components/rich-text'; import { diff --git a/packages/blocks/src/microsheet-block/microsheet-block.ts b/packages/blocks/src/microsheet-block/microsheet-block.ts index ed0a92169f7e..91d4c479c36a 100644 --- a/packages/blocks/src/microsheet-block/microsheet-block.ts +++ b/packages/blocks/src/microsheet-block/microsheet-block.ts @@ -12,6 +12,12 @@ import { toast } from '@blocksuite/affine-components/toast'; import { NOTE_SELECTOR } from '@blocksuite/affine-shared/consts'; import { DocModeProvider } from '@blocksuite/affine-shared/services'; import { RANGE_SYNC_EXCLUDE_ATTR } from '@blocksuite/block-std'; +import { Rect } from '@blocksuite/global/utils'; +import { + CopyIcon, + DeleteIcon, + MoreHorizontalIcon, +} from '@blocksuite/icons/lit'; import { createRecordDetail, createUniComponentFromWebComponent, @@ -26,14 +32,8 @@ import { MicrosheetSelection, renderUniLit, uniMap, -} from '@blocksuite/data-view'; -import { widgetPresets } from '@blocksuite/data-view/widget-presets'; -import { Rect } from '@blocksuite/global/utils'; -import { - CopyIcon, - DeleteIcon, - MoreHorizontalIcon, -} from '@blocksuite/icons/lit'; +} from '@blocksuite/microsheet-data-view'; +import { widgetPresets } from '@blocksuite/microsheet-data-view/widget-presets'; import { Slice } from '@blocksuite/store'; import { autoUpdate } from '@floating-ui/dom'; import { computed, signal } from '@preact/signals-core'; diff --git a/packages/blocks/src/microsheet-block/microsheet-service.ts b/packages/blocks/src/microsheet-block/microsheet-service.ts index 292e610e119f..7e8d74833b40 100644 --- a/packages/blocks/src/microsheet-block/microsheet-service.ts +++ b/packages/blocks/src/microsheet-block/microsheet-service.ts @@ -5,7 +5,7 @@ import { MicrosheetBlockSchema, } from '@blocksuite/affine-model'; import { BlockService } from '@blocksuite/block-std'; -import { viewPresets } from '@blocksuite/data-view/view-presets'; +import { viewPresets } from '@blocksuite/microsheet-data-view/view-presets'; import { microsheetViewAddView, @@ -40,8 +40,7 @@ export class MicrosheetBlockService extends BlockService { doc: Doc, model: BlockModel, microsheetId: string, - viewType: string, - isAppendNewRow = true + viewType: string ) { const blockModel = doc.getBlock(microsheetId)?.model as | MicrosheetBlockModel @@ -50,11 +49,6 @@ export class MicrosheetBlockService extends BlockService { return; } microsheetViewInitTemplate(blockModel, viewType); - if (isAppendNewRow) { - const parent = doc.getParent(model); - if (!parent) return; - doc.addBlock('affine:paragraph', {}, parent.id); - } applyPropertyUpdate(blockModel); } } diff --git a/packages/blocks/src/microsheet-block/microsheet-spec.ts b/packages/blocks/src/microsheet-block/microsheet-spec.ts index 537e48a4f39e..cd1c83cdb58c 100644 --- a/packages/blocks/src/microsheet-block/microsheet-spec.ts +++ b/packages/blocks/src/microsheet-block/microsheet-spec.ts @@ -3,7 +3,7 @@ import { type ExtensionType, FlavourExtension, } from '@blocksuite/block-std'; -import { MicrosheetSelectionExtension } from '@blocksuite/data-view'; +import { MicrosheetSelectionExtension } from '@blocksuite/microsheet-data-view'; import { literal } from 'lit/static-html.js'; import { MicrosheetDragHandleOption } from './config.js'; diff --git a/packages/blocks/src/microsheet-block/properties/converts.ts b/packages/blocks/src/microsheet-block/properties/converts.ts index 7a82b32ca8cc..55ea2d842de7 100644 --- a/packages/blocks/src/microsheet-block/properties/converts.ts +++ b/packages/blocks/src/microsheet-block/properties/converts.ts @@ -3,9 +3,9 @@ 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'; +} from '@blocksuite/microsheet-data-view'; +import { presetPropertyConverts } from '@blocksuite/microsheet-data-view/property-presets'; +import { propertyModelPresets } from '@blocksuite/microsheet-data-view/property-pure-presets'; import { nanoid, Text } from '@blocksuite/store'; import { richTextColumnModelConfig } from './rich-text/define.js'; diff --git a/packages/blocks/src/microsheet-block/properties/index.ts b/packages/blocks/src/microsheet-block/properties/index.ts index 855625f45ad9..d81b07078acc 100644 --- a/packages/blocks/src/microsheet-block/properties/index.ts +++ b/packages/blocks/src/microsheet-block/properties/index.ts @@ -1,6 +1,6 @@ -import type { PropertyMetaConfig } from '@blocksuite/data-view'; +import type { PropertyMetaConfig } from '@blocksuite/microsheet-data-view'; -import { propertyPresets } from '@blocksuite/data-view/property-presets'; +import { propertyPresets } from '@blocksuite/microsheet-data-view/property-presets'; import { linkColumnConfig } from './link/cell-renderer.js'; import { richTextColumnConfig } from './rich-text/cell-renderer.js'; diff --git a/packages/blocks/src/microsheet-block/properties/link/cell-renderer.ts b/packages/blocks/src/microsheet-block/properties/link/cell-renderer.ts index 7362e176a3d0..69b209cc9f19 100644 --- a/packages/blocks/src/microsheet-block/properties/link/cell-renderer.ts +++ b/packages/blocks/src/microsheet-block/properties/link/cell-renderer.ts @@ -5,12 +5,12 @@ import { normalizeUrl, stopPropagation, } from '@blocksuite/affine-shared/utils'; +import { PenIcon } from '@blocksuite/icons/lit'; import { BaseCellRenderer, createFromBaseCellRenderer, createIcon, -} from '@blocksuite/data-view'; -import { PenIcon } from '@blocksuite/icons/lit'; +} from '@blocksuite/microsheet-data-view'; import { baseTheme } from '@toeverything/theme'; import { css, unsafeCSS } from 'lit'; import { query, state } from 'lit/decorators.js'; @@ -38,8 +38,8 @@ export class LinkCell extends BaseCellRenderer { height: 100%; outline: none; overflow: hidden; - font-size: var(--data-view-cell-text-size); - line-height: var(--data-view-cell-text-line-height); + font-size: var(--microsheet-data-view-cell-text-size); + line-height: var(--microsheet-data-view-cell-text-line-height); word-break: break-all; } @@ -67,13 +67,13 @@ export class LinkCell extends BaseCellRenderer { height: 16px; fill: var(--affine-icon-color); } - .data-view-link-column-linked-doc { + .microsheet-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 { + .microsheet-data-view-link-column-linked-doc:hover { text-decoration-color: var(--affine-icon-color); } `; @@ -133,7 +133,7 @@ export class LinkCell extends BaseCellRenderer { -
`; } diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/header/column-header.ts b/packages/affine/microsheet-data-view/src/view-presets/table/header/column-header.ts index 41012b4e3cae..9f8bb2fc2e36 100644 --- a/packages/affine/microsheet-data-view/src/view-presets/table/header/column-header.ts +++ b/packages/affine/microsheet-data-view/src/view-presets/table/header/column-header.ts @@ -1,7 +1,6 @@ import { getScrollContainer } from '@blocksuite/affine-shared/utils'; import { ShadowlessElement } from '@blocksuite/block-std'; import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; -import { PlusIcon } from '@blocksuite/icons/lit'; import { autoUpdate } from '@floating-ui/dom'; import { nothing, type TemplateResult } from 'lit'; import { property, query } from 'lit/decorators.js'; @@ -87,9 +86,6 @@ export class MicrosheetColumnHeader extends SignalWatcher( return html` ${this.renderGroupHeader?.()}
- ${this.readonly - ? nothing - : html`
`} ${repeat( this.tableViewManager.properties$.value, column => column.id, @@ -98,26 +94,18 @@ export class MicrosheetColumnHeader extends SignalWatcher( width: `${column.width$.value}px`, border: index === 0 ? 'none' : undefined, }); - return html` `; + return index === 0 + ? nothing + : html` `; } )} -
-
- ${PlusIcon()} -
-
`; } diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/header/microsheet-header-column.ts b/packages/affine/microsheet-data-view/src/view-presets/table/header/microsheet-header-column.ts index 8b2be0901fad..1c14541fc345 100644 --- a/packages/affine/microsheet-data-view/src/view-presets/table/header/microsheet-header-column.ts +++ b/packages/affine/microsheet-data-view/src/view-presets/table/header/microsheet-header-column.ts @@ -11,6 +11,7 @@ import { import { ShadowlessElement } from '@blocksuite/block-std'; import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; import { + AddCursorIcon, DeleteIcon, DuplicateIcon, InsertLeftIcon, @@ -524,7 +525,6 @@ export class MicrosheetHeaderColumn extends SignalWatcher( } override render() { - const column = this.column; const style = styleMap({ height: DEFAULT_COLUMN_TITLE_HEIGHT + 'px', }); @@ -542,32 +542,41 @@ export class MicrosheetHeaderColumn extends SignalWatcher( ${this.readonly ? null : html` `} -
-
- -
-
-
- ${column.name$.value} -
-
+
+
+
+
{ + this.tableViewManager.propertyAdd({ + id: this.column.id, + before: true, + }); + }} + > +
+ ${AddCursorIcon()} +
+
{ + this.tableViewManager.propertyAdd({ + id: this.column.id, + before: false, + }); + }} + > +
+ ${AddCursorIcon()}
-
-
-
`; } diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/header/styles.ts b/packages/affine/microsheet-data-view/src/view-presets/table/header/styles.ts index 8c6ad2a21b87..ec46e7aaf44e 100644 --- a/packages/affine/microsheet-data-view/src/view-presets/table/header/styles.ts +++ b/packages/affine/microsheet-data-view/src/view-presets/table/header/styles.ts @@ -19,8 +19,8 @@ export const styles = css` position: relative; display: flex; flex-direction: row; - border-bottom: 1px solid var(--affine-border-color); - border-top: 1px solid var(--affine-border-color); + /* border-bottom: 1px solid var(--affine-border-color); + border-top: 1px solid var(--affine-border-color); */ box-sizing: border-box; user-select: none; background-color: var(--affine-background-primary-color); @@ -52,11 +52,12 @@ export const styles = css` padding: 8px; box-sizing: border-box; position: relative; + padding:0; } .affine-microsheet-column-content:hover, .affine-microsheet-column-content.edit { - background: var(--affine-hover-color); + background: blue } .affine-microsheet-column-content.edit .affine-microsheet-column-text-icon { @@ -155,6 +156,7 @@ export const styles = css` .affine-microsheet-column-move { display: flex; align-items: center; + padding: 0; } .affine-microsheet-column-move svg { @@ -351,4 +353,49 @@ export const styles = css` --delay: 0.4s; opacity: 1; } + + + .affine-microsheet-column-add-icon { + position: absolute; + // right: -12px; + left: -10px; + top: -16px; + z-index: 9; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + } + + .affine-microsheet-column-add-icon svg { + width: 20px; + height: 20px; + border-radius: 100px; + background: #4949fe; + color: white; + display: none; + } + + .affine-microsheet-column-right-add-icon { + left: unset; + right: -10px; + } + + .affine-microsheet-column-add-icon:hover svg { + display: block; + } + + .affine-microsheet-column-add-icon:hover + .affine-microsheet-column-add-not-active-icon { + display: none; + } + + .affine-microsheet-column-add-not-active-icon { + margin-top: -4px; + width: 4px; + height: 4px; + border-radius: 4px; + background: #ddd; + } `; diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/row/row.ts b/packages/affine/microsheet-data-view/src/view-presets/table/row/row.ts index 8533c72aacf0..8a73ee1c4906 100644 --- a/packages/affine/microsheet-data-view/src/view-presets/table/row/row.ts +++ b/packages/affine/microsheet-data-view/src/view-presets/table/row/row.ts @@ -38,7 +38,7 @@ export class TableRow extends SignalWatcher(WithDisposable(ShadowlessElement)) { width: 100%; display: flex; flex-direction: row; - border-bottom: 1px solid var(--affine-border-color); + /* border-bottom: 1px solid var(--affine-border-color); */ position: relative; } @@ -200,45 +200,47 @@ export class TableRow extends SignalWatcher(WithDisposable(ShadowlessElement)) { this.selectionController ); }; - return html` -
- - -
- ${!column.readonly$.value && - column.view.mainProperties$.value.titleColumn === column.id - ? html`
-
- ${CenterPeekIcon()} -
- ${!view.readonly$.value - ? html`
- ${MoreHorizontalIcon()} -
` - : nothing} -
` - : nothing} - `; + return i === 0 + ? nothing + : html` +
+ + +
+ ${!column.readonly$.value && + column.view.mainProperties$.value.titleColumn === column.id + ? html`
+
+ ${CenterPeekIcon()} +
+ ${!view.readonly$.value + ? html`
+ ${MoreHorizontalIcon()} +
` + : nothing} +
` + : nothing} + `; } )} -
+ `; } diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/table-view.ts b/packages/affine/microsheet-data-view/src/view-presets/table/table-view.ts index feb0a2b724c7..291c11bd85cf 100644 --- a/packages/affine/microsheet-data-view/src/view-presets/table/table-view.ts +++ b/packages/affine/microsheet-data-view/src/view-presets/table/table-view.ts @@ -16,7 +16,6 @@ import type { GroupManager } from '../../core/common/group-by/helper.js'; import type { DataViewExpose } from '../../core/index.js'; import type { TableSingleView } from './table-view-manager.js'; -import { renderUniLit } from '../../core/utils/uni-component/uni-component.js'; import { DataViewBase } from '../../core/view/data-view-base.js'; import { LEFT_TOOL_BAR_WIDTH } from './consts.js'; import { TableClipboardController } from './controller/clipboard.js'; @@ -33,6 +32,10 @@ const styles = css` position: relative; display: flex; flex-direction: column; + overflow: hidden; + margin-left: -16px; + padding-top: 16px; + padding-bottom: 10px; } affine-microsheet-table * { @@ -114,6 +117,7 @@ const styles = css` .microsheet-cell { border-left: 1px solid var(--affine-border-color); + border-top: 1px solid var(--affine-border-color); } .data-view-table-left-bar { @@ -132,6 +136,13 @@ const styles = css` justify-content: center; align-items: flex-start; } + + .affine-microsheet-block-rows + > affine-row:last-child + microsheet-data-view-table-row + affine-microsheet-cell-container { + border-bottom: 1px solid var(--affine-border-color); + } `; export class DataViewTable extends DataViewBase< @@ -256,23 +267,6 @@ export class DataViewTable extends DataViewBase< } private renderTable() { - const groups = this.props.view.groupManager.groupsDataList$.value; - if (groups) { - return html` -
- ${groups.map(group => { - return html` `; - })} - ${this.renderAddGroup(this.props.view.groupManager)} -
- `; - } return html`
new AttachmentBlockTransformer(), diff --git a/packages/affine/model/src/blocks/bookmark/bookmark-model.ts b/packages/affine/model/src/blocks/bookmark/bookmark-model.ts index ff15447826d1..3a2be6766c12 100644 --- a/packages/affine/model/src/blocks/bookmark/bookmark-model.ts +++ b/packages/affine/model/src/blocks/bookmark/bookmark-model.ts @@ -54,6 +54,7 @@ export const BookmarkBlockSchema = defineBlockSchema({ 'affine:edgeless-text', 'affine:paragraph', 'affine:list', + 'affine:cell', ], }, toModel: () => new BookmarkBlockModel(), diff --git a/packages/affine/model/src/blocks/code/code-model.ts b/packages/affine/model/src/blocks/code/code-model.ts index 74fbb8e33234..997ac5a21c1a 100644 --- a/packages/affine/model/src/blocks/code/code-model.ts +++ b/packages/affine/model/src/blocks/code/code-model.ts @@ -28,6 +28,7 @@ export const CodeBlockSchema = defineBlockSchema({ 'affine:paragraph', 'affine:list', 'affine:edgeless-text', + 'affine:cell', ], children: [], }, diff --git a/packages/affine/model/src/blocks/latex/latex-model.ts b/packages/affine/model/src/blocks/latex/latex-model.ts index 1e7c48d103d8..c31c457b4b63 100644 --- a/packages/affine/model/src/blocks/latex/latex-model.ts +++ b/packages/affine/model/src/blocks/latex/latex-model.ts @@ -31,6 +31,7 @@ export const LatexBlockSchema = defineBlockSchema({ 'affine:edgeless-text', 'affine:paragraph', 'affine:list', + 'affine:cell', ], }, toModel: () => { diff --git a/packages/affine/model/src/blocks/list/list-model.ts b/packages/affine/model/src/blocks/list/list-model.ts index a56d34f3db06..3d8337a1d2d9 100644 --- a/packages/affine/model/src/blocks/list/list-model.ts +++ b/packages/affine/model/src/blocks/list/list-model.ts @@ -34,6 +34,7 @@ export const ListBlockSchema = defineBlockSchema({ 'affine:list', 'affine:paragraph', 'affine:edgeless-text', + 'affine:cell', ], }, }); diff --git a/packages/affine/model/src/blocks/surface-ref/surface-ref-model.ts b/packages/affine/model/src/blocks/surface-ref/surface-ref-model.ts index 636b161b8e33..422cd52ecfbe 100644 --- a/packages/affine/model/src/blocks/surface-ref/surface-ref-model.ts +++ b/packages/affine/model/src/blocks/surface-ref/surface-ref-model.ts @@ -16,7 +16,7 @@ export const SurfaceRefBlockSchema = defineBlockSchema({ metadata: { version: 1, role: 'content', - parent: ['affine:note', 'affine:paragraph', 'affine:list'], + parent: ['affine:note', 'affine:paragraph', 'affine:list', 'affine:cell'], }, }); diff --git a/packages/blocks/src/microsheet-block/data-source.ts b/packages/blocks/src/microsheet-block/data-source.ts index 0b40ccd72943..5a22c9c9695c 100644 --- a/packages/blocks/src/microsheet-block/data-source.ts +++ b/packages/blocks/src/microsheet-block/data-source.ts @@ -152,6 +152,7 @@ export class MicrosheetBlockDataSource extends DataSourceBase { updateCell(this._model, rowId, { columnId: propertyId, value: newValue, + ref: '', }); applyCellsUpdate(this._model); } @@ -267,6 +268,7 @@ export class MicrosheetBlockDataSource extends DataSourceBase { } propertyTypeGet(propertyId: string): string { + return 'rich-text'; if (propertyId === 'type') { return 'image'; } @@ -309,13 +311,46 @@ export class MicrosheetBlockDataSource extends DataSourceBase { applyPropertyUpdate(this._model); } + refContentDelete(rowId: string, columnId: string): void { + const cellId = this.cellRefGet(rowId, columnId); + const doc = this.doc; + if (typeof cellId === 'string') { + const cellBlock = doc.getBlock(cellId); + if (cellBlock) { + const children = cellBlock.model.children; + children.forEach(b => doc.deleteBlock(b)); + doc.addBlock('affine:paragraph', {}, cellId); + } + } + } + rowAdd(insertPosition: InsertToPosition | number): string { this.doc.captureSync(); const index = typeof insertPosition === 'number' ? insertPosition : insertPositionToIndex(insertPosition, this._model.children); - return this.doc.addBlock('affine:paragraph', {}, this._model.id, index); + const rowId = this.doc.addBlock('affine:row', {}, this._model.id, index); + const columnIds = this._model.columns.map(column => column.id); + columnIds.forEach((id: string, index: number) => { + if (!index) return; + // 调用cellContainer的add + const cellContainerId = this.doc.addBlock('affine:cell', {}, rowId); + this.doc.addBlock( + 'affine:paragraph', + { + text: new this.doc.Text(``), + }, + cellContainerId + ); + + updateCell(this._model, rowId, { + columnId: id, + value: '', + ref: cellContainerId, + }); + }); + return rowId; } rowDelete(ids: string[]): void { diff --git a/packages/blocks/src/microsheet-block/microsheet-block.ts b/packages/blocks/src/microsheet-block/microsheet-block.ts index 91d4c479c36a..9207d389c1cf 100644 --- a/packages/blocks/src/microsheet-block/microsheet-block.ts +++ b/packages/blocks/src/microsheet-block/microsheet-block.ts @@ -10,9 +10,7 @@ import { DragIndicator } from '@blocksuite/affine-components/drag-indicator'; import { PeekViewProvider } from '@blocksuite/affine-components/peek'; import { toast } from '@blocksuite/affine-components/toast'; import { NOTE_SELECTOR } from '@blocksuite/affine-shared/consts'; -import { DocModeProvider } from '@blocksuite/affine-shared/services'; -import { RANGE_SYNC_EXCLUDE_ATTR } from '@blocksuite/block-std'; -import { Rect } from '@blocksuite/global/utils'; +import { Rect, Slot } from '@blocksuite/global/utils'; import { CopyIcon, DeleteIcon, @@ -33,11 +31,13 @@ import { renderUniLit, uniMap, } from '@blocksuite/microsheet-data-view'; +// eslint-disable-next-line @typescript-eslint/no-restricted-imports +import { TableRowSelection } from '@blocksuite/microsheet-data-view/src/view-presets/table/types.js'; import { widgetPresets } from '@blocksuite/microsheet-data-view/widget-presets'; import { Slice } from '@blocksuite/store'; -import { autoUpdate } from '@floating-ui/dom'; import { computed, signal } from '@preact/signals-core'; import { css, html, nothing, unsafeCSS } from 'lit'; +import { query } from 'lit/decorators.js'; import type { NoteBlockComponent } from '../note-block/index.js'; import type { MicrosheetOptionsConfig } from './config.js'; @@ -53,6 +53,7 @@ import { HostContextKey } from './context/host-context.js'; import { MicrosheetBlockDataSource } from './data-source.js'; import { BlockRenderer } from './detail-panel/block-renderer.js'; import { NoteRenderer } from './detail-panel/note-renderer.js'; +import { calculateLineNum, isInCellEnd, isInCellStart } from './utils.js'; export class MicrosheetBlockComponent extends CaptionedBlockComponent< MicrosheetBlockModel, @@ -68,6 +69,18 @@ export class MicrosheetBlockComponent extends CaptionedBlockComponent< margin: 8px -8px -8px; } + affine-microsheet:hover .affine-microsheet-column-header { + visibility: visible; + } + + affine-microsheet:hover .microsheet-data-view-table-left-bar { + visibility: visible; + } + + affine-microsheet affine-paragraph .affine-block-component { + // margin: 0 !important; + } + .microsheet-block-selected { background-color: var(--affine-hover-color); border-radius: 4px; @@ -243,6 +256,8 @@ export class MicrosheetBlockComponent extends CaptionedBlockComponent< return () => {}; }; + selectionUpdated = new Slot(); + setSelection = (selection: DataViewSelection | undefined) => { this.selection.setGroup( 'note', @@ -326,24 +341,140 @@ export class MicrosheetBlockComponent extends CaptionedBlockComponent< override connectedCallback() { super.connectedCallback(); - - this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true'); - this.listenFullWidthChange(); - } - - listenFullWidthChange() { - if (!this.doc.awarenessStore.getFlag('enable_microsheet_full_width')) { - return; - } - if (this.std.get(DocModeProvider).getEditorMode() === 'edgeless') { - return; - } - this.disposables.add( - autoUpdate(this.host, this, () => { - const padding = - this.getBoundingClientRect().left - - this.host.getBoundingClientRect().left; - this.virtualPadding$.value = Math.max(0, padding - 72); + this._disposables.add( + this.bindHotKey({ + Backspace: () => { + const selectionController = + this._DataViewTableElement?.selectionController; + const selection = selectionController?.selection; + if (!selectionController || !selection) return; + const data = this.dataSource; + if (TableRowSelection.is(selection)) { + const rows = TableRowSelection.rowsIds(selection); + selectionController.selection = undefined; + rows.forEach(rowId => { + this.model.columns.forEach(column => { + if (rowId && column.id) { + data.refContentDelete(rowId, column.id); + } + }); + }); + return; + } + const { + focus, + rowsSelection, + columnsSelection, + isEditing, + groupKey, + } = selection; + if (focus && !isEditing) { + if (rowsSelection && columnsSelection) { + // multi cell + for (let i = rowsSelection.start; i <= rowsSelection.end; i++) { + const { start, end } = columnsSelection; + for (let j = start; j <= end; j++) { + const container = selectionController.getCellContainer( + groupKey, + i, + j + ); + const rowId = container?.dataset.rowId; + const columnId = container?.dataset.columnId; + if (rowId && columnId) { + data.refContentDelete(rowId, columnId); + } + } + } + } else { + // single cell + const container = selectionController.getCellContainer( + groupKey, + focus.rowIndex, + focus.columnIndex + ); + const rowId = container?.dataset.rowId; + const columnId = container?.dataset.columnId; + if (rowId && columnId) { + data.refContentDelete(rowId, columnId); + } + } + } + }, + Tab: context => { + const selectionController = + this._DataViewTableElement?.selectionController; + if (!selectionController || !selectionController.focus) return; + context.get('keyboardState').raw.preventDefault(); + selectionController.focusToCell('right', 'start'); + return true; + }, + 'Shift-Tab': context => { + const selectionController = + this._DataViewTableElement?.selectionController; + if (!selectionController) return; + context.get('keyboardState').raw.preventDefault(); + selectionController.focusToCell('left', 'start'); + return true; + }, + ArrowLeft: context => { + const selectionController = + this._DataViewTableElement?.selectionController; + if (!selectionController) return; + if (isInCellStart(this.host.std, true)) { + const stop = selectionController.focusToCell('left'); + if (stop) { + context.get('keyboardState').raw.preventDefault(); + return true; + } + } + return; + }, + ArrowRight: context => { + const selectionController = + this._DataViewTableElement?.selectionController; + if (!selectionController || !selectionController.focus) return; + if (isInCellEnd(this.host.std, true)) { + const stop = selectionController.focusToCell('right'); + if (stop) { + context.get('keyboardState').raw.preventDefault(); + return true; + } + } + return; + }, + ArrowUp: context => { + const selectionController = + this._DataViewTableElement?.selectionController; + if (!selectionController || !selectionController.focus) return; + if (isInCellStart(this.host.std)) { + const { isFirst } = calculateLineNum(this.host.std); + if (!isFirst) return false; + + const stop = selectionController.focusToCell('up'); + if (stop) { + context.get('keyboardState').raw.preventDefault(); + return true; + } + } + return; + }, + ArrowDown: context => { + const selectionController = + this._DataViewTableElement?.selectionController; + if (!selectionController || !selectionController.focus) return; + if (isInCellEnd(this.host.std)) { + const { isLast } = calculateLineNum(this.host.std); + if (!isLast) return; + + const stop = selectionController.focusToCell('down'); + if (stop) { + context.get('keyboardState').raw.preventDefault(); + return true; + } + } + return; + }, }) ); } @@ -363,6 +494,7 @@ export class MicrosheetBlockComponent extends CaptionedBlockComponent< setSelection: this.setSelection, dataSource: this.dataSource, headerWidget: this.headerWidget, + selectionUpdated: this.selectionUpdated, onDrag: this.onDrag, std: this.std, detailPanelConfig: { @@ -402,6 +534,9 @@ export class MicrosheetBlockComponent extends CaptionedBlockComponent< `; } + @query('affine-microsheet-table') + private accessor _DataViewTableElement: DataViewTable | null = null; + override accessor useZeroWidth = true; } diff --git a/packages/blocks/src/microsheet-block/utils.ts b/packages/blocks/src/microsheet-block/utils.ts index bc6d7800d52b..98a8774795d3 100644 --- a/packages/blocks/src/microsheet-block/utils.ts +++ b/packages/blocks/src/microsheet-block/utils.ts @@ -35,6 +35,21 @@ export function addProperty( col ); }); + model.children.forEach(item => { + const cellContainerId = model.doc.addBlock('affine:cell', {}, item.id); + model.doc.addBlock( + 'affine:paragraph', + { + text: new model.doc.Text(``), + }, + cellContainerId + ); + updateCell(model, item.id, { + columnId: id, + value: '', + ref: cellContainerId, + }); + }); return id; } @@ -238,3 +253,99 @@ export const MICROSHEET_CONVERT_WHITE_LIST = [ 'affine:list', 'affine:paragraph', ]; + +const checkTypes = ['affine:paragraph', 'affine:list']; + +export function getTheOnlyTextSelection( + std: BlockStdScope +): TextSelection | null { + const value = std.selection.value; + if (value.length === 1 && value[0].type === 'text') { + return value[0] as TextSelection; + } + + return null; +} + +export function isInCellStart(std: BlockStdScope, atTextStart = false) { + const value = getTheOnlyTextSelection(std); + const doc = std.doc; + + if (value) { + const currentModel = doc.getBlockById(value.blockId); + if (currentModel && checkTypes.includes(currentModel.flavour)) { + const parentModel = doc.getParent(currentModel); + if ( + parentModel?.flavour === 'affine:cell' && + parentModel.firstChild() === currentModel + ) { + if (!atTextStart) return true; + return value.start.index === 0; + } + } + } + + return false; +} + +export function isInCellEnd(std: BlockStdScope, atTextEnd = false) { + const value = getTheOnlyTextSelection(std); + const doc = std.doc; + + if (value) { + const currentModel = doc.getBlockById(value.blockId); + if (currentModel && checkTypes.includes(currentModel.flavour)) { + const parentModel = doc.getParent(currentModel); + if ( + parentModel?.flavour === 'affine:cell' && + parentModel.lastChild() === currentModel + ) { + if (!atTextEnd) return true; + const textLength = currentModel.text?.length; + return textLength ? value.end.index === textLength : true; + } + } + } + + return false; +} + +export function calculateLineNum(std: BlockStdScope) { + const value = getTheOnlyTextSelection(std); + const doc = std.doc; + assertExists(value); + + const currentModel = doc.getBlockById(value.blockId); + const element = std.host.querySelector( + `[data-block-id="${value.blockId}"] .inline-editor` + ); + assertExists(element); + + const text = currentModel?.text?.toString().slice(0, value.start.index + 1); + assertExists(text); + + const temp = document.createElement('div'); + temp.style.margin = '0'; + temp.style.padding = '0'; + // @ts-ignore + temp.style.fontFamily = element.style.fontFamily; + // @ts-ignore + temp.style.fontSize = element.style.fontSize; + temp.style.width = element.getBoundingClientRect().width + 'px'; + + element.parentElement?.append(temp); + temp.innerHTML = 'A'; + const lineHeight = temp.clientHeight; + + temp.innerHTML = text || 'A'; + const currentHeight = temp.clientHeight; + temp?.remove(); + + const lines = Math.floor(element.getBoundingClientRect().height / lineHeight); + const line = Math.floor(currentHeight / lineHeight); + + return { + isFirst: line <= 1, + isLast: line >= lines, + }; +} diff --git a/packages/blocks/src/row-block/row-block.ts b/packages/blocks/src/row-block/row-block.ts index 40ff2795c264..9335eef912a2 100644 --- a/packages/blocks/src/row-block/row-block.ts +++ b/packages/blocks/src/row-block/row-block.ts @@ -18,6 +18,7 @@ export class RowBlockComponent extends CaptionedBlockComponent< padding: 10px; } affine-row { + border-right: 1px solid var(--affine-border-color); // border-left: 1px solid var(--affine-border-color); // border-top: 1px solid var(--affine-border-color); } From 8270e64f49fd7a58cc27da8ed986fcd37cf49198 Mon Sep 17 00:00:00 2001 From: "caojiafu@cvte.com" Date: Wed, 6 Nov 2024 19:57:10 +0800 Subject: [PATCH 04/13] feat(blocks): microsheet-block feature complete --- .../block-paragraph/src/paragraph-block.ts | 14 +- .../src/core/data-view.ts | 6 - .../src/view-presets/kanban/card.ts | 333 -------- .../src/view-presets/kanban/cell.ts | 191 ----- .../kanban/controller/clipboard.ts | 49 -- .../view-presets/kanban/controller/drag.ts | 241 ------ .../view-presets/kanban/controller/hotkeys.ts | 65 -- .../kanban/controller/selection.ts | 750 ------------------ .../src/view-presets/kanban/define.ts | 92 --- .../src/view-presets/kanban/group.ts | 210 ----- .../src/view-presets/kanban/header.ts | 71 -- .../src/view-presets/kanban/index.ts | 4 - .../kanban/kanban-view-manager.ts | 316 -------- .../src/view-presets/kanban/kanban-view.ts | 266 ------- .../src/view-presets/kanban/menu.ts | 112 --- .../src/view-presets/kanban/renderer.ts | 9 - .../src/view-presets/kanban/types.ts | 32 - .../src/view-presets/table/cell.ts | 54 +- .../table/controller/clipboard.ts | 242 +++--- .../src/view-presets/table/controller/drag.ts | 18 +- .../table/controller/selection.ts | 43 +- .../table/header/column-header.ts | 27 +- .../table/header/microsheet-header-column.ts | 16 +- .../src/view-presets/table/header/styles.ts | 61 +- .../src/view-presets/table/row/row.ts | 176 +++- .../view-presets/table/table-view-manager.ts | 10 +- .../src/view-presets/table/table-view.ts | 14 +- .../src/_common/transformers/middlewares.ts | 6 + packages/blocks/src/cell-block/cell-block.ts | 2 - .../src/cell-block/keymap-controller.ts | 33 +- .../src/microsheet-block/data-source.ts | 1 - .../src/microsheet-block/microsheet-block.ts | 45 +- packages/blocks/src/microsheet-block/utils.ts | 2 + packages/blocks/src/note-block/note-block.ts | 1 + .../src/root-block/clipboard/adapter.ts | 254 ++++++ .../blocks/src/root-block/clipboard/index.ts | 7 +- .../root-block/widgets/slash-menu/utils.ts | 8 + .../block-std/src/clipboard/index.ts | 11 + .../block-std/src/view/decorators/required.ts | 7 + packages/framework/store/src/adapter/base.ts | 2 +- .../framework/store/src/transformer/job.ts | 64 +- 41 files changed, 831 insertions(+), 3034 deletions(-) delete mode 100644 packages/affine/microsheet-data-view/src/view-presets/kanban/card.ts delete mode 100644 packages/affine/microsheet-data-view/src/view-presets/kanban/cell.ts delete mode 100644 packages/affine/microsheet-data-view/src/view-presets/kanban/controller/clipboard.ts delete mode 100644 packages/affine/microsheet-data-view/src/view-presets/kanban/controller/drag.ts delete mode 100644 packages/affine/microsheet-data-view/src/view-presets/kanban/controller/hotkeys.ts delete mode 100644 packages/affine/microsheet-data-view/src/view-presets/kanban/controller/selection.ts delete mode 100644 packages/affine/microsheet-data-view/src/view-presets/kanban/define.ts delete mode 100644 packages/affine/microsheet-data-view/src/view-presets/kanban/group.ts delete mode 100644 packages/affine/microsheet-data-view/src/view-presets/kanban/header.ts delete mode 100644 packages/affine/microsheet-data-view/src/view-presets/kanban/index.ts delete mode 100644 packages/affine/microsheet-data-view/src/view-presets/kanban/kanban-view-manager.ts delete mode 100644 packages/affine/microsheet-data-view/src/view-presets/kanban/kanban-view.ts delete mode 100644 packages/affine/microsheet-data-view/src/view-presets/kanban/menu.ts delete mode 100644 packages/affine/microsheet-data-view/src/view-presets/kanban/renderer.ts delete mode 100644 packages/affine/microsheet-data-view/src/view-presets/kanban/types.ts diff --git a/packages/affine/block-paragraph/src/paragraph-block.ts b/packages/affine/block-paragraph/src/paragraph-block.ts index c6f631cbcbb4..76332d735ca0 100644 --- a/packages/affine/block-paragraph/src/paragraph-block.ts +++ b/packages/affine/block-paragraph/src/paragraph-block.ts @@ -45,6 +45,17 @@ export class ParagraphBlockComponent extends CaptionedBlockComponent< return false; }; + private _isInMicrosheet = () => { + let parent = this.parentElement; + while (parent && parent !== document.body) { + if (parent.tagName.toLowerCase() === 'affine-microsheet') { + return true; + } + parent = parent.parentElement; + } + return false; + }; + get attributeRenderer() { return this.inlineManager.getRenderer(); } @@ -119,7 +130,8 @@ export class ParagraphBlockComponent extends CaptionedBlockComponent< .then(() => { if ( (this.inlineEditor?.yTextLength ?? 0) > 0 || - this._isInDatabase() + this._isInDatabase() || + this._isInMicrosheet() ) { this._displayPlaceholder.value = false; return; diff --git a/packages/affine/microsheet-data-view/src/core/data-view.ts b/packages/affine/microsheet-data-view/src/core/data-view.ts index 25591480f409..16e4fb951ec3 100644 --- a/packages/affine/microsheet-data-view/src/core/data-view.ts +++ b/packages/affine/microsheet-data-view/src/core/data-view.ts @@ -69,12 +69,6 @@ export class DataViewRenderer extends SignalWatcher( viewMap$ = computed(() => { const manager = this.config.dataSource.viewManager; - console.log( - 11112222222, - Object.fromEntries( - manager.views$.value.map(view => [view, manager.viewGet(view)]) - ) - ); return Object.fromEntries( manager.views$.value.map(view => [view, manager.viewGet(view)]) ); diff --git a/packages/affine/microsheet-data-view/src/view-presets/kanban/card.ts b/packages/affine/microsheet-data-view/src/view-presets/kanban/card.ts deleted file mode 100644 index 9336b3422daf..000000000000 --- a/packages/affine/microsheet-data-view/src/view-presets/kanban/card.ts +++ /dev/null @@ -1,333 +0,0 @@ -import { popupTargetFromElement } from '@blocksuite/affine-components/context-menu'; -import { ShadowlessElement } from '@blocksuite/block-std'; -import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; -import { CenterPeekIcon, MoreHorizontalIcon } from '@blocksuite/icons/lit'; -import { css } from 'lit'; -import { property, state } from 'lit/decorators.js'; -import { classMap } from 'lit/directives/class-map.js'; -import { repeat } from 'lit/directives/repeat.js'; -import { html } from 'lit/static-html.js'; - -import type { DataViewRenderer } from '../../core/data-view.js'; -import type { KanbanColumn, KanbanSingleView } from './kanban-view-manager.js'; - -import { openDetail, popCardMenu } from './menu.js'; - -const styles = css` - affine-microsheet-data-view-kanban-card { - display: flex; - position: relative; - flex-direction: column; - border: 1px solid var(--affine-border-color); - box-shadow: 0px 2px 3px 0px rgba(0, 0, 0, 0.05); - border-radius: 8px; - transition: background-color 100ms ease-in-out; - background-color: var(--affine-background-kanban-card-color); - } - - affine-microsheet-data-view-kanban-card:hover { - background-color: var(--affine-hover-color); - } - - affine-microsheet-data-view-kanban-card .card-header { - padding: 8px; - display: flex; - flex-direction: column; - gap: 8px; - } - - affine-microsheet-data-view-kanban-card - .card-header-title - microsheet-uni-lit { - width: 100%; - } - - .card-header.has-divider { - border-bottom: 0.5px solid var(--affine-border-color); - } - - affine-microsheet-data-view-kanban-card .card-header-title { - font-size: var(--data-view-cell-text-size); - line-height: var(--data-view-cell-text-line-height); - } - - affine-microsheet-data-view-kanban-card .card-header-icon { - padding: 4px; - background-color: var(--affine-background-secondary-color); - display: flex; - align-items: center; - border-radius: 4px; - width: max-content; - } - - affine-microsheet-data-view-kanban-card .card-header-icon svg { - width: 16px; - height: 16px; - fill: var(--affine-icon-color); - color: var(--affine-icon-color); - } - - affine-microsheet-data-view-kanban-card .card-body { - display: flex; - flex-direction: column; - padding: 8px; - gap: 4px; - } - - affine-microsheet-data-view-kanban-card:hover .card-ops { - visibility: visible; - } - - .card-ops { - position: absolute; - right: 8px; - top: 8px; - visibility: hidden; - display: flex; - gap: 4px; - cursor: pointer; - } - - .card-op { - display: flex; - position: relative; - padding: 4px; - border-radius: 4px; - box-shadow: 0px 0px 4px 0px rgba(66, 65, 73, 0.14); - background-color: var(--affine-background-primary-color); - } - - .card-op:hover:before { - content: ''; - border-radius: 4px; - position: absolute; - left: 0; - right: 0; - top: 0; - bottom: 0; - background-color: var(--affine-hover-color); - } - - .card-op svg { - fill: var(--affine-icon-color); - color: var(--affine-icon-color); - width: 16px; - height: 16px; - } -`; - -export class KanbanCard extends SignalWatcher( - WithDisposable(ShadowlessElement) -) { - static override styles = styles; - - private clickEdit = (e: MouseEvent) => { - e.stopPropagation(); - const selection = this.getSelection(); - if (selection) { - openDetail(this.dataViewEle, this.cardId, selection); - } - }; - - private clickMore = (e: MouseEvent) => { - e.stopPropagation(); - const selection = this.getSelection(); - const ele = e.currentTarget as HTMLElement; - if (selection) { - selection.selection = { - selectionType: 'card', - cards: [ - { - groupKey: this.groupKey, - cardId: this.cardId, - }, - ], - }; - popCardMenu( - this.dataViewEle, - popupTargetFromElement(ele), - this.cardId, - selection - ); - } - }; - - private contextMenu = (e: MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - const selection = this.getSelection(); - if (selection) { - selection.selection = { - selectionType: 'card', - cards: [ - { - groupKey: this.groupKey, - cardId: this.cardId, - }, - ], - }; - const target = e.target as HTMLElement; - const ref = - target.closest('affine-microsheet-data-view-kanban-cell') ?? this; - popCardMenu( - this.dataViewEle, - popupTargetFromElement(ref), - this.cardId, - selection - ); - } - }; - - private getSelection() { - return this.closest('affine-microsheet-data-view-kanban') - ?.selectionController; - } - - private renderBody(columns: KanbanColumn[]) { - if (columns.length === 0) { - return ''; - } - return html`
- ${repeat( - columns, - v => v.id, - column => { - if (this.view.isInHeader(column.id)) { - return ''; - } - return html` `; - } - )} -
`; - } - - private renderHeader(columns: KanbanColumn[]) { - if (!this.view.hasHeader(this.cardId)) { - return ''; - } - const classList = classMap({ - 'card-header': true, - 'has-divider': columns.length > 0, - }); - return html` -
${this.renderTitle()} ${this.renderIcon()}
- `; - } - - private renderIcon() { - const icon = this.view.getHeaderIcon(this.cardId); - if (!icon) { - return; - } - return html`
- ${icon.cellGet(this.cardId).value$.value} -
`; - } - - private renderOps() { - if (this.view.readonly$.value) { - return; - } - return html` -
-
- ${CenterPeekIcon()} -
-
- ${MoreHorizontalIcon()} -
-
- `; - } - - private renderTitle() { - const title = this.view.getHeaderTitle(this.cardId); - if (!title) { - return; - } - return html`
- -
`; - } - - override connectedCallback() { - super.connectedCallback(); - if (this.view.readonly$.value) { - return; - } - this._disposables.addFromEvent(this, 'contextmenu', e => { - this.contextMenu(e); - }); - this._disposables.addFromEvent(this, 'click', e => { - if (e.shiftKey) { - this.getSelection()?.shiftClickCard(e); - return; - } - const selection = this.getSelection(); - const preSelection = selection?.selection; - - if (preSelection?.selectionType !== 'card') return; - - if (selection) { - selection.selection = undefined; - } - this.dataViewEle.openDetailPanel({ - view: this.view, - rowId: this.cardId, - onClose: () => { - if (selection) { - selection.selection = preSelection; - } - }, - }); - }); - } - - override render() { - const columns = this.view.properties$.value.filter( - v => !this.view.isInHeader(v.id) - ); - this.style.border = this.isFocus - ? '1px solid var(--affine-primary-color)' - : ''; - return html` - ${this.renderHeader(columns)} ${this.renderBody(columns)} - ${this.renderOps()} - `; - } - - @property({ attribute: false }) - accessor cardId!: string; - - @property({ attribute: false }) - accessor dataViewEle!: DataViewRenderer; - - @property({ attribute: false }) - accessor groupKey!: string; - - @state() - accessor isFocus = false; - - @property({ attribute: false }) - accessor view!: KanbanSingleView; -} - -declare global { - interface HTMLElementTagNameMap { - 'affine-microsheet-data-view-kanban-card': KanbanCard; - } -} diff --git a/packages/affine/microsheet-data-view/src/view-presets/kanban/cell.ts b/packages/affine/microsheet-data-view/src/view-presets/kanban/cell.ts deleted file mode 100644 index ae5a9d9a7fd2..000000000000 --- a/packages/affine/microsheet-data-view/src/view-presets/kanban/cell.ts +++ /dev/null @@ -1,191 +0,0 @@ -// related component - -import { ShadowlessElement } from '@blocksuite/block-std'; -import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; -import { css } from 'lit'; -import { property, state } from 'lit/decorators.js'; -import { createRef } from 'lit/directives/ref.js'; -import { html } from 'lit/static-html.js'; - -import type { - CellRenderProps, - DataViewCellLifeCycle, -} from '../../core/property/index.js'; -import type { Property } from '../../core/view-manager/property.js'; -import type { KanbanSingleView } from './kanban-view-manager.js'; -import type { KanbanViewSelection } from './types.js'; - -import { renderUniLit } from '../../core/utils/uni-component/uni-component.js'; - -const styles = css` - affine-microsheet-data-view-kanban-cell { - border-radius: 4px; - display: flex; - align-items: center; - padding: 4px; - min-height: 20px; - border: 1px solid transparent; - box-sizing: border-box; - } - - affine-microsheet-data-view-kanban-cell:hover { - background-color: var(--affine-hover-color); - } - - affine-microsheet-data-view-kanban-cell .icon { - display: flex; - align-items: center; - justify-content: center; - align-self: start; - margin-right: 12px; - height: var(--data-view-cell-text-line-height); - } - - affine-microsheet-data-view-kanban-cell .icon svg { - width: 16px; - height: 16px; - fill: var(--affine-icon-color); - color: var(--affine-icon-color); - } - - .kanban-cell { - flex: 1; - display: block; - width: 196px; - } -`; - -export class KanbanCell extends SignalWatcher( - WithDisposable(ShadowlessElement) -) { - static override styles = styles; - - private _cell = createRef(); - - selectCurrentCell = (editing: boolean) => { - const selectionView = this.closest( - 'affine-microsheet-data-view-kanban' - )?.selectionController; - if (!selectionView) return; - if (selectionView) { - const selection = selectionView.selection; - if (selection && this.isSelected(selection) && editing) { - selectionView.selection = { - selectionType: 'cell', - groupKey: this.groupKey, - cardId: this.cardId, - columnId: this.column.id, - isEditing: true, - }; - } else { - selectionView.selection = { - selectionType: 'cell', - groupKey: this.groupKey, - cardId: this.cardId, - columnId: this.column.id, - isEditing: false, - }; - } - } - }; - - get cell(): DataViewCellLifeCycle | undefined { - return this._cell.value; - } - - get selection() { - return this.closest('affine-microsheet-data-view-kanban') - ?.selectionController; - } - - override connectedCallback() { - super.connectedCallback(); - this._disposables.addFromEvent(this, 'click', e => { - if (e.shiftKey) { - return; - } - e.stopPropagation(); - const selectionElement = this.closest( - 'affine-microsheet-data-view-kanban' - )?.selectionController; - if (!selectionElement) return; - if (e.shiftKey) return; - - if (!this.editing) { - this.selectCurrentCell(!this.column.readonly$.value); - } - }); - } - - isSelected(selection: KanbanViewSelection) { - if ( - selection.selectionType !== 'cell' || - selection.groupKey !== this.groupKey - ) { - return; - } - return ( - selection.cardId === this.cardId && selection.columnId === this.column.id - ); - } - - override render() { - const props: CellRenderProps = { - cell: this.column.cellGet(this.cardId), - isEditing: this.editing, - selectCurrentCell: this.selectCurrentCell, - }; - const renderer = this.column.renderer$.value; - if (!renderer) return; - const { view, edit } = renderer; - this.style.border = this.isFocus - ? '1px solid var(--affine-primary-color)' - : ''; - this.style.boxShadow = this.editing - ? '0px 0px 0px 2px rgba(30, 150, 235, 0.30)' - : ''; - return html` ${this.renderIcon()} - ${renderUniLit(this.editing && edit ? edit : view, props, { - ref: this._cell, - class: 'kanban-cell', - style: { display: 'block', flex: '1', overflow: 'hidden' }, - })}`; - } - - renderIcon() { - if (this.contentOnly) { - return; - } - return html` `; - } - - @property({ attribute: false }) - accessor cardId!: string; - - @property({ attribute: false }) - accessor column!: Property; - - @property({ attribute: false }) - accessor contentOnly = false; - - @state() - accessor editing = false; - - @property({ attribute: false }) - accessor groupKey!: string; - - @state() - accessor isFocus = false; - - @property({ attribute: false }) - accessor view!: KanbanSingleView; -} - -declare global { - interface HTMLElementTagNameMap { - 'affine-microsheet-data-view-kanban-cell': KanbanCell; - } -} diff --git a/packages/affine/microsheet-data-view/src/view-presets/kanban/controller/clipboard.ts b/packages/affine/microsheet-data-view/src/view-presets/kanban/controller/clipboard.ts deleted file mode 100644 index d9ebb08fe980..000000000000 --- a/packages/affine/microsheet-data-view/src/view-presets/kanban/controller/clipboard.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { UIEventStateContext } from '@blocksuite/block-std'; -import type { ReactiveController } from 'lit'; - -import type { DataViewKanban } from '../kanban-view.js'; -import type { KanbanViewSelectionWithType } from '../types.js'; - -export class KanbanClipboardController implements ReactiveController { - private _onCopy = ( - _context: UIEventStateContext, - _kanbanSelection: KanbanViewSelectionWithType - ) => { - // todo - return true; - }; - - private _onPaste = (_context: UIEventStateContext) => { - // todo - return true; - }; - - private get readonly() { - return this.host.props.view.readonly$.value; - } - - constructor(public host: DataViewKanban) { - host.addController(this); - } - - hostConnected() { - this.host.disposables.add( - this.host.props.handleEvent('copy', ctx => { - const kanbanSelection = this.host.selectionController.selection; - if (!kanbanSelection) return false; - - this._onCopy(ctx, kanbanSelection); - return true; - }) - ); - - this.host.disposables.add( - this.host.props.handleEvent('paste', ctx => { - if (this.readonly) return false; - - this._onPaste(ctx); - return true; - }) - ); - } -} diff --git a/packages/affine/microsheet-data-view/src/view-presets/kanban/controller/drag.ts b/packages/affine/microsheet-data-view/src/view-presets/kanban/controller/drag.ts deleted file mode 100644 index 8927188508dc..000000000000 --- a/packages/affine/microsheet-data-view/src/view-presets/kanban/controller/drag.ts +++ /dev/null @@ -1,241 +0,0 @@ -import type { InsertToPosition } from '@blocksuite/affine-shared/utils'; -import type { ReactiveController } from 'lit'; - -import { assertExists, Point, Rect } from '@blocksuite/global/utils'; - -import type { DataViewKanban } from '../kanban-view.js'; - -import { startDrag } from '../../../core/utils/drag.js'; -import { autoScrollOnBoundary } from '../../../core/utils/frame-loop.js'; -import { KanbanCard } from '../card.js'; -import { KanbanGroup } from '../group.js'; - -export class KanbanDragController implements ReactiveController { - dragStart = (ele: KanbanCard, evt: PointerEvent) => { - const eleRect = ele.getBoundingClientRect(); - const offsetLeft = evt.x - eleRect.left; - const offsetTop = evt.y - eleRect.top; - const preview = createDragPreview( - ele, - evt.x - offsetLeft, - evt.y - offsetTop - ); - const currentGroup = ele.closest( - 'affine-microsheet-data-view-kanban-group' - ); - const cancelScroll = autoScrollOnBoundary(this.scrollContainer); - startDrag< - | { type: 'out'; callback: () => void } - | { - type: 'self'; - key: string; - position: InsertToPosition; - } - | undefined, - PointerEvent - >(evt, { - onDrag: () => undefined, - onMove: evt => { - if (!(evt.target instanceof HTMLElement)) { - return; - } - preview.display(evt.x - offsetLeft, evt.y - offsetTop); - if (!Rect.fromDOM(this.host).isPointIn(Point.from(evt))) { - const callback = this.host.props.onDrag; - if (callback) { - this.dropPreview.remove(); - return { - type: 'out', - callback: callback(evt, ele.cardId), - }; - } - return; - } - const result = this.shooIndicator(evt, ele); - if (result) { - return { - type: 'self', - key: result.group.group.key, - position: result.position, - }; - } - return; - }, - onClear: () => { - preview.remove(); - this.dropPreview.remove(); - cancelScroll(); - }, - onDrop: result => { - if (!result) { - return; - } - if (result.type === 'out') { - result.callback(); - return; - } - if (result && currentGroup) { - currentGroup.group.manager.moveCardTo( - ele.cardId, - currentGroup.group.key, - result.key, - result.position - ); - } - }, - }); - }; - - dropPreview = createDropPreview(); - - getInsertPosition = ( - evt: MouseEvent - ): - | { group: KanbanGroup; card?: KanbanCard; position: InsertToPosition } - | undefined => { - const eles = document.elementsFromPoint(evt.x, evt.y); - const target = eles.find(v => v instanceof KanbanGroup) as KanbanGroup; - if (target) { - const card = getCardByPoint(target, evt.y); - return { - group: target, - card, - position: card - ? { - before: true, - id: card.cardId, - } - : 'end', - }; - } else { - return; - } - }; - - shooIndicator = ( - evt: MouseEvent, - self: KanbanCard | undefined - ): { group: KanbanGroup; position: InsertToPosition } | undefined => { - const position = this.getInsertPosition(evt); - if (position) { - this.dropPreview.display(position.group, self, position.card); - } else { - this.dropPreview.remove(); - } - return position; - }; - - get scrollContainer() { - const scrollContainer = this.host.querySelector( - '.affine-microsheet-data-view-kanban-groups' - ) as HTMLElement; - assertExists(scrollContainer); - return scrollContainer; - } - - constructor(private host: DataViewKanban) { - this.host.addController(this); - } - - hostConnected() { - if (this.host.props.view.readonly$.value) { - return; - } - this.host.disposables.add( - this.host.props.handleEvent('dragStart', context => { - const event = context.get('pointerState').raw; - const target = event.target; - if (target instanceof Element) { - const cell = target.closest( - 'affine-microsheet-data-view-kanban-cell' - ); - if (cell?.editing) { - return; - } - cell?.selectCurrentCell(false); - const card = target.closest( - 'affine-microsheet-data-view-kanban-card' - ); - if (card) { - this.dragStart(card, event); - } - } - return true; - }) - ); - } -} - -const createDragPreview = (card: KanbanCard, x: number, y: number) => { - const preOpacity = card.style.opacity; - card.style.opacity = '0.5'; - const div = document.createElement('div'); - const kanbanCard = new KanbanCard(); - kanbanCard.cardId = card.cardId; - kanbanCard.view = card.view; - kanbanCard.isFocus = true; - kanbanCard.style.backgroundColor = 'var(--affine-background-primary-color)'; - div.append(kanbanCard); - div.className = 'with-data-view-css-variable'; - div.style.width = `${card.getBoundingClientRect().width}px`; - div.style.position = 'fixed'; - // div.style.pointerEvents = 'none'; - div.style.transform = 'rotate(-3deg)'; - div.style.left = `${x}px`; - div.style.top = `${y}px`; - div.style.zIndex = '9999'; - document.body.append(div); - return { - display(x: number, y: number) { - div.style.left = `${Math.round(x)}px`; - div.style.top = `${Math.round(y)}px`; - }, - remove() { - card.style.opacity = preOpacity; - div.remove(); - }, - }; -}; -const createDropPreview = () => { - const div = document.createElement('div'); - div.style.height = '2px'; - div.style.borderRadius = '1px'; - div.style.backgroundColor = 'var(--affine-primary-color)'; - div.style.boxShadow = '0px 0px 8px 0px rgba(30, 150, 235, 0.35)'; - return { - display( - group: KanbanGroup, - self: KanbanCard | undefined, - card?: KanbanCard - ) { - const target = card ?? group.querySelector('.add-card'); - assertExists(target); - if (target.previousElementSibling === self || target === self) { - div.remove(); - return; - } - if (target.previousElementSibling === div) { - return; - } - target.insertAdjacentElement('beforebegin', div); - }, - remove() { - div.remove(); - }, - }; -}; - -const getCardByPoint = ( - group: KanbanGroup, - y: number -): KanbanCard | undefined => { - const cards = Array.from( - group.querySelectorAll('affine-microsheet-data-view-kanban-card') - ); - const positions = cards.map(v => { - const rect = v.getBoundingClientRect(); - return (rect.top + rect.bottom) / 2; - }); - const index = positions.findIndex(v => v > y); - return cards[index]; -}; diff --git a/packages/affine/microsheet-data-view/src/view-presets/kanban/controller/hotkeys.ts b/packages/affine/microsheet-data-view/src/view-presets/kanban/controller/hotkeys.ts deleted file mode 100644 index 75bc90f70655..000000000000 --- a/packages/affine/microsheet-data-view/src/view-presets/kanban/controller/hotkeys.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { ReactiveController } from 'lit'; - -import type { DataViewKanban } from '../kanban-view.js'; - -export class KanbanHotkeysController implements ReactiveController { - private get hasSelection() { - return !!this.host.selectionController.selection; - } - - constructor(private host: DataViewKanban) { - this.host.addController(this); - } - - hostConnected() { - this.host.disposables.add( - this.host.props.bindHotkey({ - Escape: () => { - this.host.selectionController.focusOut(); - return true; - }, - Enter: () => { - this.host.selectionController.focusIn(); - }, - ArrowUp: context => { - if (!this.hasSelection) return false; - - this.host.selectionController.focusNext('up'); - context.get('keyboardState').raw.preventDefault(); - return true; - }, - ArrowDown: context => { - if (!this.hasSelection) return false; - - this.host.selectionController.focusNext('down'); - context.get('keyboardState').raw.preventDefault(); - return true; - }, - Tab: context => { - if (!this.hasSelection) return false; - - this.host.selectionController.focusNext('down'); - context.get('keyboardState').raw.preventDefault(); - return true; - }, - ArrowLeft: context => { - if (!this.hasSelection) return false; - - this.host.selectionController.focusNext('left'); - context.get('keyboardState').raw.preventDefault(); - return true; - }, - ArrowRight: context => { - if (!this.hasSelection) return false; - - this.host.selectionController.focusNext('right'); - context.get('keyboardState').raw.preventDefault(); - return true; - }, - Backspace: () => { - this.host.selectionController.deleteCard(); - }, - }) - ); - } -} diff --git a/packages/affine/microsheet-data-view/src/view-presets/kanban/controller/selection.ts b/packages/affine/microsheet-data-view/src/view-presets/kanban/controller/selection.ts deleted file mode 100644 index 7538ed6448fb..000000000000 --- a/packages/affine/microsheet-data-view/src/view-presets/kanban/controller/selection.ts +++ /dev/null @@ -1,750 +0,0 @@ -import type { ReactiveController } from 'lit'; - -import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; -import { assertExists } from '@blocksuite/global/utils'; - -import type { KanbanGroup } from '../group.js'; -import type { DataViewKanban } from '../kanban-view.js'; -import type { - KanbanCardSelection, - KanbanCardSelectionCard, - KanbanCellSelection, - KanbanGroupSelection, - KanbanViewSelection, - KanbanViewSelectionWithType, -} from '../types.js'; - -import { KanbanCard } from '../card.js'; -import { KanbanCell } from '../cell.js'; - -export class KanbanSelectionController implements ReactiveController { - _selection?: KanbanViewSelectionWithType; - - shiftClickCard = (event: MouseEvent) => { - event.preventDefault(); - - const selection = this.selection; - const target = event.target as HTMLElement; - const closestCardId = target.closest( - 'affine-microsheet-data-view-kanban-card' - )?.cardId; - const closestGroupKey = target.closest( - 'affine-microsheet-data-view-kanban-group' - )?.group.key; - if (!closestCardId) return; - if (!closestGroupKey) return; - const cards = selection?.selectionType === 'card' ? selection.cards : []; - - const newCards = cards.some(card => card.cardId === closestCardId) - ? cards.filter(card => card.cardId !== closestCardId) - : [...cards, { cardId: closestCardId, groupKey: closestGroupKey }]; - this.selection = atLeastOne(newCards) - ? { - selectionType: 'card', - cards: newCards, - } - : undefined; - }; - - get selection(): KanbanViewSelectionWithType | undefined { - return this._selection; - } - - set selection(data: KanbanViewSelection | undefined) { - if (!data) { - this.host.props.setSelection(); - return; - } - const selection: KanbanViewSelectionWithType = { - ...data, - viewId: this.host.props.view.id, - type: 'kanban', - }; - - if (selection.selectionType === 'cell' && selection.isEditing) { - const container = getFocusCell(this.host, selection); - const cell = container?.cell; - const isEditing = cell - ? cell.beforeEnterEditMode() - ? selection.isEditing - : false - : false; - this.host.props.setSelection({ - ...selection, - isEditing, - }); - } else { - this.host.props.setSelection(selection); - } - } - - get view() { - return this.host.props.view; - } - - constructor(private host: DataViewKanban) { - this.host.addController(this); - } - - blur(selection: KanbanViewSelection) { - if (selection.selectionType !== 'cell') { - const selectCards = getSelectedCards(this.host, selection); - selectCards.forEach(card => (card.isFocus = false)); - return; - } - const container = getFocusCell(this.host, selection); - if (!container) { - return; - } - container.isFocus = false; - const cell = container?.cell; - - if (selection.isEditing) { - requestAnimationFrame(() => { - cell?.onExitEditMode(); - }); - if (cell?.blurCell()) { - container.blur(); - } - container.editing = false; - } else { - container.blur(); - } - } - - deleteCard() { - const selection = this.selection; - if (!selection || selection.selectionType === 'cell') { - return; - } - if (selection.selectionType === 'card') { - this.host.props.view.rowDelete(selection.cards.map(v => v.cardId)); - this.selection = undefined; - } - } - - focus(selection: KanbanViewSelection) { - if (selection.selectionType !== 'cell') { - const selectCards = getSelectedCards(this.host, selection); - selectCards.forEach((card, index) => { - if (index === 0) { - card.scrollIntoView({ block: 'nearest', inline: 'nearest' }); - } - card.isFocus = true; - }); - return; - } - const container = getFocusCell(this.host, selection); - if (!container) { - return; - } - container.scrollIntoView({ block: 'nearest', inline: 'nearest' }); - container.isFocus = true; - const cell = container?.cell; - if (selection.isEditing) { - cell?.onEnterEditMode(); - if (cell?.focusCell()) { - container.focus(); - } - container.editing = true; - } else { - container.focus(); - } - } - - focusFirstCell() { - const group = this.host.groupManager?.groupsDataList$.value?.[0]; - const card = group?.rows[0]; - const columnId = card && this.host.props.view.getHeaderTitle(card)?.id; - if (group && card && columnId) { - this.selection = { - selectionType: 'cell', - groupKey: group.key, - cardId: card, - columnId, - isEditing: false, - }; - } - } - - focusIn() { - const selection = this.selection; - if (!selection) return; - if (selection.selectionType === 'cell' && selection.isEditing) return; - - if (selection.selectionType === 'cell') { - this.selection = { - ...selection, - isEditing: true, - }; - return; - } - if (selection.selectionType === 'card') { - const card = getSelectedCards(this.host, selection)[0]; - const cell = card?.querySelector( - 'affine-microsheet-data-view-kanban-cell' - ); - if (cell) { - this.selection = { - groupKey: card.groupKey, - cardId: card.cardId, - selectionType: 'cell', - columnId: cell.column.id, - isEditing: false, - }; - } - } else { - // Not yet implement - } - } - - focusNext(position: 'up' | 'down' | 'left' | 'right') { - const selection = this.selection; - if (!selection) { - return; - } - - if (selection.selectionType === 'cell' && !selection.isEditing) { - // cell focus - const kanbanCells = getCardCellsBySelection(this.host, selection); - const index = kanbanCells.findIndex( - cell => cell.column.id === selection.columnId - ); - const { cell, cardId, groupKey } = this.getNextFocusCell( - selection, - index, - position - ); - if (cell instanceof KanbanCell) { - this.selection = { - ...selection, - cardId: cardId ?? selection.cardId, - groupKey: groupKey ?? selection.groupKey, - columnId: cell.column.id, - } satisfies KanbanCellSelection; - } - } else if (selection.selectionType === 'card') { - // card focus - const group = this.host.querySelector( - `affine-microsheet-data-view-kanban-group[data-key="${selection.cards[0].groupKey}"]` - ); - const cardElements = Array.from( - group?.querySelectorAll('affine-microsheet-data-view-kanban-card') ?? [] - ); - - const index = cardElements.findIndex( - card => card.cardId === selection.cards[0].cardId - ); - const { card, cards } = this.getNextFocusCard(selection, index, position); - if (card instanceof KanbanCard) { - const newCards = cards ?? selection.cards; - this.selection = atLeastOne(newCards) - ? { - ...selection, - cards: newCards, - } - : undefined; - } - } - } - - focusOut() { - const selection = this.selection; - if (selection?.selectionType === 'card') { - if (atLeastOne(selection.cards)) { - this.selection = { - ...selection, - cards: [selection.cards[0]], - }; - } else { - // Not yet implement - return; - } - } - if (selection?.selectionType !== 'cell') { - return; - } - - if (selection.isEditing) { - this.selection = { - ...selection, - isEditing: false, - }; - } else { - this.selection = { - selectionType: 'card', - cards: [ - { - cardId: selection.cardId, - groupKey: selection.groupKey, - }, - ], - }; - } - } - - getNextFocusCard( - selection: KanbanCardSelection, - index: number, - nextPosition: 'up' | 'down' | 'left' | 'right' - ): { card: KanbanCard; cards: KanbanCardSelectionCard[] } { - const group = this.host.querySelector( - `affine-microsheet-data-view-kanban-group[data-key="${selection.cards[0].groupKey}"]` - ); - const kanbanCards = Array.from( - group?.querySelectorAll('affine-microsheet-data-view-kanban-card') ?? [] - ); - - if (nextPosition === 'up') { - const nextIndex = index - 1; - const nextCardIndex = nextIndex < 0 ? kanbanCards.length - 1 : nextIndex; - const card = kanbanCards[nextCardIndex]; - - return { - card, - cards: [ - { - cardId: card.cardId, - groupKey: card.groupKey, - }, - ], - }; - } - - if (nextPosition === 'down') { - const nextIndex = index + 1; - const nextCardIndex = nextIndex > kanbanCards.length - 1 ? 0 : nextIndex; - const card = kanbanCards[nextCardIndex]; - - return { - card, - cards: [ - { - cardId: card.cardId, - groupKey: card.groupKey, - }, - ], - }; - } - - const groups = Array.from( - this.host.querySelectorAll('affine-microsheet-data-view-kanban-group') - ); - - if (nextPosition === 'right') { - return getNextGroupFocusElement( - this.host, - groups, - selection, - groupIndex => (groupIndex === groups.length - 1 ? 0 : groupIndex + 1) - ); - } - - if (nextPosition === 'left') { - return getNextGroupFocusElement( - this.host, - groups, - selection, - groupIndex => (groupIndex === 0 ? groups.length - 1 : groupIndex - 1) - ); - } - throw new BlockSuiteError( - ErrorCode.MicrosheetBlockError, - 'Unknown arrow keys, only support: up, down, left, and right keys.' - ); - } - - getNextFocusCell( - selection: KanbanCellSelection, - index: number, - nextPosition: 'up' | 'down' | 'left' | 'right' - ): { - cell: KanbanCell; - cardId?: string; - groupKey?: string; - } { - const kanbanCells = getCardCellsBySelection(this.host, selection); - const group = this.host.querySelector( - `affine-microsheet-data-view-kanban-group[data-key="${selection.groupKey}"]` - ); - const cards = Array.from( - group?.querySelectorAll('affine-microsheet-data-view-kanban-card') ?? [] - ); - - if (nextPosition === 'up') { - const nextIndex = index - 1; - if (nextIndex < 0) { - if (cards.length > 1) { - return getNextCardFocusCell( - nextPosition, - cards, - selection, - cardIndex => (cardIndex === 0 ? cards.length - 1 : cardIndex - 1) - ); - } else { - return { - cell: kanbanCells[kanbanCells.length - 1], - }; - } - } - return { - cell: kanbanCells[nextIndex], - }; - } - - if (nextPosition === 'down') { - const nextIndex = index + 1; - if (nextIndex >= kanbanCells.length) { - if (cards.length > 1) { - return getNextCardFocusCell( - nextPosition, - cards, - selection, - cardIndex => (cardIndex === cards.length - 1 ? 0 : cardIndex + 1) - ); - } else { - return { - cell: kanbanCells[0], - }; - } - } - return { - cell: kanbanCells[nextIndex], - }; - } - - const groups = Array.from( - this.host.querySelectorAll('affine-microsheet-data-view-kanban-group') - ); - - if (nextPosition === 'right') { - return getNextGroupFocusElement( - this.host, - groups, - selection, - groupIndex => (groupIndex === groups.length - 1 ? 0 : groupIndex + 1) - ); - } - - if (nextPosition === 'left') { - return getNextGroupFocusElement( - this.host, - groups, - selection, - groupIndex => (groupIndex === 0 ? groups.length - 1 : groupIndex - 1) - ); - } - throw new BlockSuiteError( - ErrorCode.MicrosheetBlockError, - 'Unknown arrow keys, only support: up, down, left, and right keys.' - ); - } - - hostConnected() { - this.host.disposables.add( - this.host.props.selection$.subscribe(selection => { - const old = this._selection; - if (old) { - this.blur(old); - } - this._selection = selection; - if (selection) { - this.focus(selection); - } - }) - ); - } - - insertRowAfter() { - const selection = this.selection; - if (selection?.selectionType !== 'card') { - return; - } - - const { cardId, groupKey } = selection.cards[0]; - const id = this.view.addCard({ before: false, id: cardId }, groupKey); - - requestAnimationFrame(() => { - const columnId = this.view.mainProperties$.value.titleColumn; - if (columnId) { - this.selection = { - selectionType: 'cell', - groupKey, - cardId: id, - columnId, - isEditing: true, - }; - } else { - this.selection = { - selectionType: 'card', - cards: [ - { - cardId: id, - groupKey, - }, - ], - }; - } - }); - } - - insertRowBefore() { - const selection = this.selection; - if (selection?.selectionType !== 'card') { - return; - } - - const { cardId, groupKey } = selection.cards[0]; - const id = this.view.addCard({ before: true, id: cardId }, groupKey); - - requestAnimationFrame(() => { - const columnId = this.view.mainProperties$.value.titleColumn; - if (columnId) { - this.selection = { - selectionType: 'cell', - groupKey, - cardId: id, - columnId, - isEditing: true, - }; - } else { - this.selection = { - selectionType: 'card', - cards: [ - { - cardId: id, - groupKey, - }, - ], - }; - } - }); - } - - moveCard(rowId: string, key: string) { - const selection = this.selection; - if (selection?.selectionType !== 'card') { - return; - } - this.view.groupManager.moveCardTo( - rowId, - selection.cards[0].groupKey, - key, - 'start' - ); - requestAnimationFrame(() => { - if (this.selection?.selectionType !== 'card') return; - - const newCards = selection.cards.map(card => ({ - ...card, - groupKey: card.groupKey, - })); - this.selection = atLeastOne(newCards) - ? { - ...selection, - cards: newCards, - } - : undefined; - }); - } -} - -type NextFocusCell = { - cell: KanbanCell; - cardId: string; - groupKey: string; -}; -type NextFocusCard = { - card: KanbanCard; - cards: { - cardId: string; - groupKey: string; - }[]; -}; -function getNextGroupFocusElement( - viewElement: Element, - groups: KanbanGroup[], - selection: KanbanCellSelection, - getNextGroupIndex: (groupIndex: number) => number -): NextFocusCell; -function getNextGroupFocusElement( - viewElement: Element, - groups: KanbanGroup[], - selection: KanbanCardSelection, - getNextGroupIndex: (groupIndex: number) => number -): NextFocusCard; -function getNextGroupFocusElement( - viewElement: Element, - groups: KanbanGroup[], - selection: KanbanCellSelection | KanbanCardSelection, - getNextGroupIndex: (groupIndex: number) => number -): NextFocusCell | NextFocusCard { - const groupIndex = groups.findIndex(group => { - if (selection.selectionType === 'cell') { - return group.group.key === selection.groupKey; - } - return group.group.key === selection.cards[0].groupKey; - }); - - let nextGroupIndex = getNextGroupIndex(groupIndex); - let nextGroup = groups[nextGroupIndex]; - while (nextGroup.group.rows.length === 0) { - nextGroupIndex = getNextGroupIndex(nextGroupIndex); - nextGroup = groups[nextGroupIndex]; - } - - const element = - selection.selectionType === 'cell' - ? getFocusCell(viewElement, selection) - : getSelectedCards(viewElement, selection)[0]; - assertExists(element); - const rect = element.getBoundingClientRect(); - const nextCards = Array.from( - nextGroup.querySelectorAll('affine-microsheet-data-view-kanban-card') - ); - const cardPos = nextCards - .map((card, index) => { - const targetRect = card.getBoundingClientRect(); - return { - offsetY: getYOffset(rect, targetRect), - index, - }; - }) - .reduce((prev, curr) => { - if (prev.offsetY < curr.offsetY) { - return prev; - } - return curr; - }); - const nextCard = nextCards[cardPos.index]; - - if (selection.selectionType === 'card') { - return { - card: nextCard, - cards: [ - { - cardId: nextCard.cardId, - groupKey: nextGroup.group.key, - }, - ], - }; - } - - const cells = Array.from( - nextCard.querySelectorAll('affine-microsheet-data-view-kanban-cell') - ); - const cellPos = cells - .map((card, index) => { - const targetRect = card.getBoundingClientRect(); - return { - offsetY: getYOffset(rect, targetRect), - index, - }; - }) - .reduce((prev, curr) => { - if (prev.offsetY < curr.offsetY) { - return prev; - } - return curr; - }); - const nextCell = cells[cellPos.index]; - - return { - cell: nextCell, - cardId: nextCard.cardId, - groupKey: nextGroup.group.key, - }; -} - -function getNextCardFocusCell( - nextPosition: 'up' | 'down', - cards: KanbanCard[], - selection: KanbanCellSelection, - getNextCardIndex: (cardIndex: number) => number -): { - cell: KanbanCell; - cardId: string; -} { - const cardIndex = cards.findIndex(card => card.cardId === selection.cardId); - const nextCardIndex = getNextCardIndex(cardIndex); - const nextCard = cards[nextCardIndex]; - const nextCells = Array.from( - nextCard.querySelectorAll('affine-microsheet-data-view-kanban-cell') - ); - const nextCellIndex = nextPosition === 'up' ? nextCells.length - 1 : 0; - return { - cell: nextCells[nextCellIndex], - cardId: nextCard.cardId, - }; -} - -function getCardCellsBySelection( - viewElement: Element, - selection: KanbanCellSelection -) { - const card = getSelectedCard(viewElement, selection); - return Array.from( - card?.querySelectorAll('affine-microsheet-data-view-kanban-cell') ?? [] - ); -} - -function getSelectedCard( - viewElement: Element, - selection: KanbanCellSelection -): KanbanCard | null { - const group = viewElement.querySelector( - `affine-microsheet-data-view-kanban-group[data-key="${selection.groupKey}"]` - ); - - if (!group) return null; - return group.querySelector( - `affine-microsheet-data-view-kanban-card[data-card-id="${selection.cardId}"]` - ); -} - -function getSelectedCards( - viewElement: Element, - selection: KanbanCardSelection | KanbanGroupSelection -): KanbanCard[] { - if (selection.selectionType === 'group') return []; - - const groupKeys = selection.cards.map(card => card.groupKey); - const groups = groupKeys - .map(key => - viewElement.querySelector( - `affine-microsheet-data-view-kanban-group[data-key="${key}"]` - ) - ) - .filter((group): group is Element => group !== null); - - const cardIds = selection.cards.map(card => card.cardId); - const cards = groups - .flatMap(group => - cardIds.map(id => - group.querySelector( - `affine-microsheet-data-view-kanban-card[data-card-id="${id}"]` - ) - ) - ) - .filter((card): card is KanbanCard => card !== null); - - return cards; -} - -function getFocusCell(viewElement: Element, selection: KanbanCellSelection) { - const card = getSelectedCard(viewElement, selection); - return card?.querySelector( - `affine-microsheet-data-view-kanban-cell[data-column-id="${selection.columnId}"]` - ); -} - -function getYOffset(srcRect: DOMRect, targetRect: DOMRect) { - return Math.abs( - srcRect.top + - (srcRect.bottom - srcRect.top) / 2 - - (targetRect.top + (targetRect.bottom - targetRect.top) / 2) - ); -} -const atLeastOne = (v: T[]): v is [T, ...T[]] => { - return v.length > 0; -}; diff --git a/packages/affine/microsheet-data-view/src/view-presets/kanban/define.ts b/packages/affine/microsheet-data-view/src/view-presets/kanban/define.ts deleted file mode 100644 index 8a5a722df219..000000000000 --- a/packages/affine/microsheet-data-view/src/view-presets/kanban/define.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; - -import type { FilterGroup } from '../../core/common/ast.js'; -import type { GroupBy, GroupProperty, Sort } from '../../core/common/types.js'; - -import { - defaultGroupBy, - groupByMatcher, - isTArray, - tRichText, - tString, - tTag, -} from '../../core/index.js'; -import { type BasicViewDataType, viewType } from '../../core/view/data-view.js'; -import { KanbanSingleView } from './kanban-view-manager.js'; - -export const kanbanViewType = viewType('kanban'); - -export type KanbanViewColumn = { - id: string; - hide?: boolean; -}; - -type DataType = { - columns: KanbanViewColumn[]; - filter: FilterGroup; - groupBy?: GroupBy; - sort?: Sort; - header: { - titleColumn?: string; - iconColumn?: string; - coverColumn?: string; - }; - groupProperties: GroupProperty[]; -}; -export type KanbanViewData = BasicViewDataType< - typeof kanbanViewType.type, - DataType ->; -export const kanbanViewModel = kanbanViewType.createModel({ - defaultName: 'Kanban View', - dataViewManager: KanbanSingleView, - defaultData: viewManager => { - const columns = viewManager.dataSource.properties$.value; - const allowList = columns.filter(columnId => { - const dataType = viewManager.dataSource.propertyDataTypeGet(columnId); - return dataType && !!groupByMatcher.match(dataType); - }); - const getWeight = (columnId: string) => { - const dataType = viewManager.dataSource.propertyDataTypeGet(columnId); - if (!dataType || tString.is(dataType) || tRichText.is(dataType)) { - return 0; - } - if (tTag.is(dataType)) { - return 3; - } - if (isTArray(dataType)) { - return 2; - } - return 1; - }; - const columnId = allowList.sort((a, b) => getWeight(b) - getWeight(a))[0]; - const type = viewManager.dataSource.propertyTypeGet(columnId); - const meta = type && viewManager.dataSource.propertyMetaGet(type); - const data = viewManager.dataSource.propertyDataGet(columnId); - if (!columnId || !meta || !data) { - throw new BlockSuiteError( - ErrorCode.MicrosheetBlockError, - 'not implement yet' - ); - } - return { - columns: columns.map(id => ({ - id: id, - hide: false, - })), - filter: { - type: 'group', - op: 'and', - conditions: [], - }, - groupBy: defaultGroupBy(meta, columnId, data), - header: { - titleColumn: viewManager.dataSource.properties$.value.find( - id => viewManager.dataSource.propertyTypeGet(id) === 'title' - ), - iconColumn: 'type', - }, - groupProperties: [], - }; - }, -}); diff --git a/packages/affine/microsheet-data-view/src/view-presets/kanban/group.ts b/packages/affine/microsheet-data-view/src/view-presets/kanban/group.ts deleted file mode 100644 index 00e89306e485..000000000000 --- a/packages/affine/microsheet-data-view/src/view-presets/kanban/group.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { - menu, - popFilterableSimpleMenu, - popupTargetFromElement, -} from '@blocksuite/affine-components/context-menu'; -import { ShadowlessElement } from '@blocksuite/block-std'; -import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; -import { AddCursorIcon } from '@blocksuite/icons/lit'; -import { css, nothing } from 'lit'; -import { property } from 'lit/decorators.js'; -import { repeat } from 'lit/directives/repeat.js'; -import { html } from 'lit/static-html.js'; - -import type { GroupData } from '../../core/common/group-by/helper.js'; -import type { DataViewRenderer } from '../../core/data-view.js'; -import type { KanbanSingleView } from './kanban-view-manager.js'; - -import { GroupTitle } from '../../core/common/group-by/group-title.js'; - -const styles = css` - affine-microsheet-data-view-kanban-group { - width: 260px; - flex-shrink: 0; - border-radius: 8px; - display: flex; - flex-direction: column; - } - - .group-header { - height: 32px; - padding: 6px 4px; - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - overflow: hidden; - } - - .group-header-title { - overflow: hidden; - display: flex; - align-items: center; - gap: 8px; - font-size: var(--data-view-cell-text-size); - } - - affine-microsheet-data-view-kanban-group:hover .group-header-op { - visibility: visible; - opacity: 1; - } - - .group-body { - margin-top: 4px; - display: flex; - flex-direction: column; - padding: 0 4px; - gap: 12px; - } - - .add-card { - display: flex; - align-items: center; - padding: 4px; - border-radius: 4px; - cursor: pointer; - font-size: var(--data-view-cell-text-size); - line-height: var(--data-view-cell-text-line-height); - visibility: hidden; - opacity: 0; - transition: all 150ms cubic-bezier(0.42, 0, 1, 1); - color: var(--affine-text-secondary-color); - } - - affine-microsheet-data-view-kanban-group:hover .add-card { - visibility: visible; - opacity: 1; - } - - affine-microsheet-data-view-kanban-group .add-card:hover { - background-color: var(--affine-hover-color); - color: var(--affine-text-primary-color); - } - - .sortable-ghost { - background-color: var(--affine-hover-color); - opacity: 0.5; - } - - .sortable-drag { - background-color: var(--affine-background-primary-color); - } -`; - -export class KanbanGroup extends SignalWatcher( - WithDisposable(ShadowlessElement) -) { - static override styles = styles; - - private clickAddCard = () => { - const id = this.view.addCard('end', this.group.key); - requestAnimationFrame(() => { - const kanban = this.closest('affine-microsheet-data-view-kanban'); - if (kanban) { - kanban.selectionController.selection = { - selectionType: 'cell', - groupKey: this.group.key, - cardId: id, - columnId: - this.view.mainProperties$.value.titleColumn || - this.view.propertyIds$.value[0], - isEditing: true, - }; - } - }); - }; - - private clickAddCardInStart = () => { - const id = this.view.addCard('start', this.group.key); - requestAnimationFrame(() => { - const kanban = this.closest('affine-microsheet-data-view-kanban'); - if (kanban) { - kanban.selectionController.selection = { - selectionType: 'cell', - groupKey: this.group.key, - cardId: id, - columnId: - this.view.mainProperties$.value.titleColumn || - this.view.propertyIds$.value[0], - isEditing: true, - }; - } - }); - }; - - private clickGroupOptions = (e: MouseEvent) => { - const ele = e.currentTarget as HTMLElement; - popFilterableSimpleMenu(popupTargetFromElement(ele), [ - menu.action({ - name: 'Ungroup', - hide: () => this.group.value == null, - select: () => { - this.group.rows.forEach(id => { - this.group.manager.removeFromGroup(id, this.group.key); - }); - }, - }), - menu.action({ - name: 'Delete Cards', - select: () => { - this.view.rowDelete(this.group.rows); - }, - }), - ]); - }; - - override render() { - const cards = this.group.rows; - return html` -
- ${GroupTitle(this.group, { - readonly: this.view.readonly$.value, - clickAdd: this.clickAddCardInStart, - clickOps: this.clickGroupOptions, - })} -
-
- ${repeat( - cards, - id => id, - id => { - return html` - - `; - } - )} - ${this.view.readonly$.value - ? nothing - : html`
-
- ${AddCursorIcon()} -
- Add -
`} -
- `; - } - - @property({ attribute: false }) - accessor dataViewEle!: DataViewRenderer; - - @property({ attribute: false }) - accessor group!: GroupData; - - @property({ attribute: false }) - accessor view!: KanbanSingleView; -} - -declare global { - interface HTMLElementTagNameMap { - 'affine-microsheet-data-view-kanban-group': KanbanGroup; - } -} diff --git a/packages/affine/microsheet-data-view/src/view-presets/kanban/header.ts b/packages/affine/microsheet-data-view/src/view-presets/kanban/header.ts deleted file mode 100644 index f766a06a6de3..000000000000 --- a/packages/affine/microsheet-data-view/src/view-presets/kanban/header.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { - menu, - popMenu, - popupTargetFromElement, -} from '@blocksuite/affine-components/context-menu'; -import { ShadowlessElement } from '@blocksuite/block-std'; -import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; -import { css } from 'lit'; -import { property } from 'lit/decorators.js'; -import { html } from 'lit/static-html.js'; - -import type { KanbanSingleView } from './kanban-view-manager.js'; - -const styles = css` - affine-microsheet-data-view-kanban-header { - display: flex; - justify-content: space-between; - padding: 4px; - } - - .select-group { - border-radius: 8px; - padding: 4px 8px; - cursor: pointer; - } - - .select-group:hover { - background-color: var(--affine-hover-color); - } -`; - -export class KanbanHeader extends SignalWatcher( - WithDisposable(ShadowlessElement) -) { - static override styles = styles; - - private clickGroup = (e: MouseEvent) => { - popMenu(popupTargetFromElement(e.target as HTMLElement), { - options: { - items: this.view.properties$.value - .filter(column => column.id !== this.view.view?.groupBy?.columnId) - .map(column => { - return menu.action({ - name: column.name$.value, - select: () => { - this.view.changeGroup(column.id); - }, - }); - }), - }, - }); - }; - - override render() { - return html` -
-
-
Group
-
- `; - } - - @property({ attribute: false }) - accessor view!: KanbanSingleView; -} - -declare global { - interface HTMLElementTagNameMap { - 'affine-microsheet-data-view-kanban-header': KanbanHeader; - } -} diff --git a/packages/affine/microsheet-data-view/src/view-presets/kanban/index.ts b/packages/affine/microsheet-data-view/src/view-presets/kanban/index.ts deleted file mode 100644 index 4000e8247279..000000000000 --- a/packages/affine/microsheet-data-view/src/view-presets/kanban/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './define.js'; -export * from './kanban-view.js'; -export * from './kanban-view-manager.js'; -export * from './renderer.js'; diff --git a/packages/affine/microsheet-data-view/src/view-presets/kanban/kanban-view-manager.ts b/packages/affine/microsheet-data-view/src/view-presets/kanban/kanban-view-manager.ts deleted file mode 100644 index 09f174655aad..000000000000 --- a/packages/affine/microsheet-data-view/src/view-presets/kanban/kanban-view-manager.ts +++ /dev/null @@ -1,316 +0,0 @@ -import { - insertPositionToIndex, - type InsertToPosition, -} from '@blocksuite/affine-shared/utils'; -import { computed, type ReadonlySignal } from '@preact/signals-core'; - -import type { TType } from '../../core/logical/typesystem.js'; -import type { KanbanViewData } from './define.js'; - -import { emptyFilterGroup, type FilterGroup } from '../../core/common/ast.js'; -import { defaultGroupBy } from '../../core/common/group-by.js'; -import { - GroupManager, - sortByManually, -} from '../../core/common/group-by/helper.js'; -import { groupByMatcher } from '../../core/common/group-by/matcher.js'; -import { evalFilter } from '../../core/logical/eval-filter.js'; -import { PropertyBase } from '../../core/view-manager/property.js'; -import { SingleViewBase } from '../../core/view-manager/single-view.js'; - -export class KanbanSingleView extends SingleViewBase { - propertiesWithoutFilter$ = computed(() => { - const needShow = new Set(this.dataSource.properties$.value); - const result: string[] = []; - this.data$.value?.columns.forEach(v => { - if (needShow.has(v.id)) { - result.push(v.id); - needShow.delete(v.id); - } - }); - result.push(...needShow); - return result; - }); - - detailProperties$ = computed(() => { - return this.propertiesWithoutFilter$.value.filter( - id => this.propertyTypeGet(id) !== 'title' - ); - }); - - filter$ = computed(() => { - return this.data$.value?.filter ?? emptyFilterGroup; - }); - - groupBy$ = computed(() => { - return this.data$.value?.groupBy; - }); - - groupManager = new GroupManager(this.groupBy$, this, { - sortGroup: ids => - sortByManually( - ids, - v => v, - this.view?.groupProperties.map(v => v.key) ?? [] - ), - sortRow: (key, ids) => { - const property = this.view?.groupProperties.find(v => v.key === key); - return sortByManually(ids, v => v, property?.manuallyCardSort ?? []); - }, - changeGroupSort: keys => { - const map = new Map(this.view?.groupProperties.map(v => [v.key, v])); - this.dataUpdate(() => { - return { - groupProperties: keys.map(key => { - const property = map.get(key); - if (property) { - return property; - } - return { - key, - hide: false, - manuallyCardSort: [], - }; - }), - }; - }); - }, - changeRowSort: (groupKeys, groupKey, keys) => { - const map = new Map(this.view?.groupProperties.map(v => [v.key, v])); - this.dataUpdate(() => { - return { - groupProperties: groupKeys.map(key => { - if (key === groupKey) { - const group = map.get(key); - return group - ? { - ...group, - manuallyCardSort: keys, - } - : { - key, - hide: false, - manuallyCardSort: keys, - }; - } else { - return ( - map.get(key) ?? { - key, - hide: false, - manuallyCardSort: [], - } - ); - } - }), - }; - }); - }, - }); - - mainProperties$ = computed(() => { - return ( - this.data$.value?.header ?? { - titleColumn: this.propertiesWithoutFilter$.value.find( - id => this.propertyTypeGet(id) === 'title' - ), - iconColumn: 'type', - } - ); - }); - - propertyIds$: ReadonlySignal = computed(() => { - return this.propertiesWithoutFilter$.value.filter( - id => !this.propertyHideGet(id) - ); - }); - - readonly$ = computed(() => { - return this.manager.readonly$.value; - }); - - get columns(): string[] { - return this.propertiesWithoutFilter$.value.filter( - id => !this.propertyHideGet(id) - ); - } - - get columnsWithoutFilter(): string[] { - const needShow = new Set(this.dataSource.properties$.value); - const result: string[] = []; - this.view?.columns.forEach(v => { - if (needShow.has(v.id)) { - result.push(v.id); - needShow.delete(v.id); - } - }); - result.push(...needShow); - return result; - } - - get filter(): FilterGroup { - return this.view?.filter ?? emptyFilterGroup; - } - - get header() { - return this.view?.header; - } - - get type(): string { - return this.view?.mode ?? 'kanban'; - } - - get view() { - return this.data$.value; - } - - addCard(position: InsertToPosition, group: string) { - const id = this.rowAdd(position); - this.groupManager.addToGroup(id, group); - return id; - } - - changeGroup(columnId: string) { - const column = this.propertyGet(columnId); - this.dataUpdate(_view => { - return { - groupBy: defaultGroupBy( - this.propertyMetaGet(column.type$.value), - column.id, - column.data$.value - ), - }; - }); - } - - checkGroup(columnId: string, type: TType, target: TType): boolean { - if (!groupByMatcher.isMatched(type, target)) { - this.changeGroup(columnId); - return false; - } - return true; - } - - filterSet(filter: FilterGroup): void { - this.dataUpdate(() => { - return { - filter, - }; - }); - } - - getHeaderCover(_rowId: string): KanbanColumn | undefined { - const columnId = this.view?.header.coverColumn; - if (!columnId) { - return; - } - return this.propertyGet(columnId); - } - - getHeaderIcon(_rowId: string): KanbanColumn | undefined { - const columnId = this.view?.header.iconColumn; - if (!columnId) { - return; - } - return this.propertyGet(columnId); - } - - getHeaderTitle(_rowId: string): KanbanColumn | undefined { - const columnId = this.view?.header.titleColumn; - if (!columnId) { - return; - } - return this.propertyGet(columnId); - } - - hasHeader(_rowId: string): boolean { - const hd = this.view?.header; - if (!hd) { - return false; - } - return !!hd.titleColumn || !!hd.iconColumn || !!hd.coverColumn; - } - - isInHeader(columnId: string) { - const hd = this.view?.header; - if (!hd) { - return false; - } - return ( - hd.titleColumn === columnId || - hd.iconColumn === columnId || - hd.coverColumn === columnId - ); - } - - isShow(rowId: string): boolean { - if (this.filter$.value?.conditions.length) { - const rowMap = Object.fromEntries( - this.properties$.value.map(column => [ - column.id, - column.cellGet(rowId).jsonValue$.value, - ]) - ); - return evalFilter(this.filter$.value, rowMap); - } - return true; - } - - propertyGet(columnId: string): KanbanColumn { - return new KanbanColumn(this, columnId); - } - - propertyHideGet(columnId: string): boolean { - return this.view?.columns.find(v => v.id === columnId)?.hide ?? false; - } - - propertyHideSet(columnId: string, hide: boolean): void { - this.dataUpdate(view => { - return { - columns: view.columns.map(v => - v.id === columnId - ? { - ...v, - hide, - } - : v - ), - }; - }); - } - - propertyMove(columnId: string, toAfterOfColumn: InsertToPosition): void { - this.dataUpdate(view => { - const columnIndex = view.columns.findIndex(v => v.id === columnId); - if (columnIndex < 0) { - return {}; - } - const columns = [...view.columns]; - const [column] = columns.splice(columnIndex, 1); - const index = insertPositionToIndex(toAfterOfColumn, columns); - columns.splice(index, 0, column); - return { - columns, - }; - }); - } - - override rowMove(rowId: string, position: InsertToPosition): void { - this.dataSource.rowMove(rowId, position); - } - - override rowNextGet(rowId: string): string { - const index = this.rows$.value.indexOf(rowId); - return this.rows$.value[index + 1]; - } - - override rowPrevGet(rowId: string): string { - const index = this.rows$.value.indexOf(rowId); - return this.rows$.value[index - 1]; - } -} - -export class KanbanColumn extends PropertyBase { - constructor(dataViewManager: KanbanSingleView, columnId: string) { - super(dataViewManager, columnId); - } -} diff --git a/packages/affine/microsheet-data-view/src/view-presets/kanban/kanban-view.ts b/packages/affine/microsheet-data-view/src/view-presets/kanban/kanban-view.ts deleted file mode 100644 index fcd6a95a99b2..000000000000 --- a/packages/affine/microsheet-data-view/src/view-presets/kanban/kanban-view.ts +++ /dev/null @@ -1,266 +0,0 @@ -import { - menu, - popMenu, - popupTargetFromElement, -} from '@blocksuite/affine-components/context-menu'; -import { AddCursorIcon } from '@blocksuite/icons/lit'; -import { css } from 'lit'; -import { query } from 'lit/decorators.js'; -import { repeat } from 'lit/directives/repeat.js'; -import { styleMap } from 'lit/directives/style-map.js'; -import { html } from 'lit/static-html.js'; -import Sortable from 'sortablejs'; - -import type { KanbanSingleView } from './kanban-view-manager.js'; -import type { KanbanViewSelectionWithType } from './types.js'; - -import { type DataViewExpose, renderUniLit } from '../../core/index.js'; -import { DataViewBase } from '../../core/view/data-view-base.js'; -import { KanbanClipboardController } from './controller/clipboard.js'; -import { KanbanDragController } from './controller/drag.js'; -import { KanbanHotkeysController } from './controller/hotkeys.js'; -import { KanbanSelectionController } from './controller/selection.js'; -import { KanbanGroup } from './group.js'; - -const styles = css` - affine-microsheet-data-view-kanban { - user-select: none; - display: flex; - flex-direction: column; - } - - .affine-microsheet-data-view-kanban-groups { - position: relative; - z-index: 1; - display: flex; - gap: 20px; - padding-bottom: 4px; - overflow-x: scroll; - overflow-y: hidden; - } - - .affine-microsheet-data-view-kanban-groups:hover { - padding-bottom: 0px; - } - - .affine-microsheet-data-view-kanban-groups::-webkit-scrollbar { - -webkit-appearance: none; - display: block; - } - - .affine-microsheet-data-view-kanban-groups::-webkit-scrollbar:horizontal { - height: 4px; - } - - .affine-microsheet-data-view-kanban-groups::-webkit-scrollbar-thumb { - border-radius: 2px; - background-color: transparent; - } - - .affine-microsheet-data-view-kanban-groups:hover::-webkit-scrollbar:horizontal { - height: 8px; - } - - .affine-microsheet-data-view-kanban-groups:hover::-webkit-scrollbar-thumb { - border-radius: 16px; - background-color: var(--affine-black-30); - } - - .affine-microsheet-data-view-kanban-groups:hover::-webkit-scrollbar-track { - background-color: var(--affine-hover-color); - } - - .add-group-icon { - padding: 4px; - border-radius: 4px; - display: flex; - align-items: center; - cursor: pointer; - } - - .add-group-icon:hover { - background-color: var(--affine-hover-color); - } - - .add-group-icon svg { - width: 16px; - height: 16px; - fill: var(--affine-icon-color); - color: var(--affine-icon-color); - } -`; - -export class DataViewKanban extends DataViewBase< - KanbanSingleView, - KanbanViewSelectionWithType -> { - static override styles = styles; - - private dragController = new KanbanDragController(this); - - clipboardController = new KanbanClipboardController(this); - - selectionController = new KanbanSelectionController(this); - - expose: DataViewExpose = { - focusFirstCell: () => { - this.selectionController.focusFirstCell(); - }, - getSelection: () => { - return this.selectionController.selection; - }, - hideIndicator: () => { - this.dragController.dropPreview.remove(); - }, - moveTo: (id, evt) => { - const position = this.dragController.getInsertPosition(evt); - if (position) { - position.group.group.manager.moveCardTo( - id, - '', - position.group.group.key, - position.position - ); - } - }, - showIndicator: evt => { - return this.dragController.shooIndicator(evt, undefined) != null; - }, - }; - - hotkeysController = new KanbanHotkeysController(this); - - onWheel = (event: WheelEvent) => { - if (event.metaKey || event.ctrlKey) { - return; - } - const ele = event.currentTarget; - if (ele instanceof HTMLElement) { - if (ele.scrollWidth === ele.clientWidth) { - return; - } - event.stopPropagation(); - } - }; - - renderAddGroup = () => { - const addGroup = this.groupManager.addGroup; - if (!addGroup) { - return; - } - const add = (e: MouseEvent) => { - const ele = e.currentTarget as HTMLElement; - popMenu(popupTargetFromElement(ele), { - options: { - items: [ - menu.input({ - onComplete: text => { - const column = this.groupManager.property$.value; - if (column) { - column.dataUpdate( - () => addGroup(text, column.data$.value) as never - ); - } - }, - }), - ], - }, - }); - }; - return html`
-
${AddCursorIcon()}
-
`; - }; - - get groupManager() { - return this.props.view.groupManager; - } - - override firstUpdated() { - const sortable = Sortable.create(this.groups, { - group: `kanban-group-drag-${this.props.view.id}`, - handle: '.group-header', - draggable: 'affine-microsheet-data-view-kanban-group', - animation: 100, - onEnd: evt => { - if (evt.item instanceof KanbanGroup) { - const groups = Array.from( - this.groups.querySelectorAll( - 'affine-microsheet-data-view-kanban-group' - ) - ); - - const key = - evt.newIndex != null - ? groups[evt.newIndex - 1]?.group.key - : undefined; - this.groupManager?.moveGroupTo( - evt.item.group.key, - key - ? { - before: false, - id: key, - } - : 'start' - ); - } - }, - }); - this._disposables.add({ - dispose: () => { - sortable.destroy(); - }, - }); - } - - override render() { - const groups = this.groupManager.groupsDataList$.value; - if (!groups) { - return html``; - } - const vPadding = this.props.virtualPadding$.value; - const wrapperStyle = styleMap({ - marginLeft: `-${vPadding}px`, - marginRight: `-${vPadding}px`, - paddingLeft: `${vPadding}px`, - paddingRight: `${vPadding}px`, - }); - return html` - ${renderUniLit(this.props.headerWidget, { - view: this.props.view, - viewMethods: this.expose, - })} -
- ${repeat( - groups, - group => group.key, - group => { - return html` `; - } - )} - ${this.renderAddGroup()} -
- `; - } - - @query('.affine-microsheet-data-view-kanban-groups') - accessor groups!: HTMLElement; -} - -declare global { - interface HTMLElementTagNameMap { - 'affine-microsheet-data-view-kanban': DataViewKanban; - } -} diff --git a/packages/affine/microsheet-data-view/src/view-presets/kanban/menu.ts b/packages/affine/microsheet-data-view/src/view-presets/kanban/menu.ts deleted file mode 100644 index a7e621dd54dc..000000000000 --- a/packages/affine/microsheet-data-view/src/view-presets/kanban/menu.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { - menu, - popFilterableSimpleMenu, - type PopupTarget, -} from '@blocksuite/affine-components/context-menu'; -import { - ArrowRightBigIcon, - DeleteIcon, - ExpandFullIcon, - MoveLeftIcon, - MoveRightIcon, -} from '@blocksuite/icons/lit'; -import { html } from 'lit'; - -import type { DataViewRenderer } from '../../core/data-view.js'; -import type { KanbanSelectionController } from './controller/selection.js'; - -export const openDetail = ( - dataViewEle: DataViewRenderer, - rowId: string, - selection: KanbanSelectionController -) => { - const old = selection.selection; - selection.selection = undefined; - dataViewEle.openDetailPanel({ - view: selection.view, - rowId: rowId, - onClose: () => { - selection.selection = old; - }, - }); -}; - -export const popCardMenu = ( - dataViewEle: DataViewRenderer, - ele: PopupTarget, - rowId: string, - selection: KanbanSelectionController -) => { - popFilterableSimpleMenu(ele, [ - menu.action({ - name: 'Expand Card', - prefix: ExpandFullIcon(), - select: () => { - openDetail(dataViewEle, rowId, selection); - }, - }), - menu.subMenu({ - name: 'Move To', - prefix: ArrowRightBigIcon(), - options: { - items: - selection.view.groupManager.groupsDataList$.value - ?.filter(v => { - const cardSelection = selection.selection; - if (cardSelection?.selectionType === 'card') { - return v.key !== cardSelection?.cards[0].groupKey; - } - return false; - }) - .map(group => { - return menu.action({ - name: group.value != null ? group.name : 'Ungroup', - select: () => { - selection.moveCard(rowId, group.key); - }, - }); - }) ?? [], - }, - }), - menu.group({ - name: '', - items: [ - menu.action({ - name: 'Insert Before', - prefix: html`
- ${MoveLeftIcon()} -
`, - select: () => { - selection.insertRowBefore(); - }, - }), - menu.action({ - name: 'Insert After', - prefix: html`
- ${MoveRightIcon()} -
`, - select: () => { - selection.insertRowAfter(); - }, - }), - ], - }), - menu.group({ - name: '', - items: [ - menu.action({ - name: 'Delete Card', - class: 'delete-item', - prefix: DeleteIcon(), - select: () => { - selection.deleteCard(); - }, - }), - ], - }), - ]); -}; diff --git a/packages/affine/microsheet-data-view/src/view-presets/kanban/renderer.ts b/packages/affine/microsheet-data-view/src/view-presets/kanban/renderer.ts deleted file mode 100644 index 47ae144de002..000000000000 --- a/packages/affine/microsheet-data-view/src/view-presets/kanban/renderer.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createUniComponentFromWebComponent } from '../../core/index.js'; -import { createIcon } from '../../core/utils/uni-icon.js'; -import { kanbanViewModel } from './define.js'; -import { DataViewKanban } from './kanban-view.js'; - -export const kanbanViewMeta = kanbanViewModel.createMeta({ - icon: createIcon('MicrosheetKanbanViewIcon'), - view: createUniComponentFromWebComponent(DataViewKanban), -}); diff --git a/packages/affine/microsheet-data-view/src/view-presets/kanban/types.ts b/packages/affine/microsheet-data-view/src/view-presets/kanban/types.ts deleted file mode 100644 index f79dbdcf5139..000000000000 --- a/packages/affine/microsheet-data-view/src/view-presets/kanban/types.ts +++ /dev/null @@ -1,32 +0,0 @@ -type WithKanbanViewType = T extends unknown - ? { - viewId: string; - type: 'kanban'; - } & T - : never; - -export type KanbanCellSelection = { - selectionType: 'cell'; - groupKey: string; - cardId: string; - columnId: string; - isEditing: boolean; -}; -export type KanbanCardSelectionCard = { - groupKey: string; - cardId: string; -}; -export type KanbanCardSelection = { - selectionType: 'card'; - cards: [KanbanCardSelectionCard, ...KanbanCardSelectionCard[]]; -}; -export type KanbanGroupSelection = { - selectionType: 'group'; - groupKeys: [string, ...string[]]; -}; -export type KanbanViewSelection = - | KanbanCellSelection - | KanbanCardSelection - | KanbanGroupSelection; -export type KanbanViewSelectionWithType = - WithKanbanViewType; diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/cell.ts b/packages/affine/microsheet-data-view/src/view-presets/table/cell.ts index d396de198a18..2c11f77331b3 100644 --- a/packages/affine/microsheet-data-view/src/view-presets/table/cell.ts +++ b/packages/affine/microsheet-data-view/src/view-presets/table/cell.ts @@ -1,6 +1,9 @@ +import type { RichText } from '@blocksuite/affine-components/rich-text'; + import { type BlockStdScope, ShadowlessElement } from '@blocksuite/block-std'; import { assertExists, + noop, SignalWatcher, WithDisposable, } from '@blocksuite/global/utils'; @@ -30,6 +33,7 @@ export class MicrosheetCellContainer extends SignalWatcher( height: 100%; border: none; outline: none; + padding: 2px 8px; } affine-microsheet-cell-container * { @@ -85,16 +89,37 @@ export class MicrosheetCellContainer extends SignalWatcher( }; } - if (focusTo && this.std) { - const richTexts = this.querySelectorAll('rich-text'); + assertExists(this.refModel); + + const focus = () => { + if (focusTo && this.std) { + const richTexts = this.querySelectorAll('rich-text'); - if (richTexts.length) { - if (focusTo === 'start') { - richTexts[0].inlineEditor?.focusStart(); - } else { - richTexts[richTexts.length - 1].inlineEditor?.focusEnd(); + if (richTexts.length) { + if (focusTo === 'start') { + (richTexts[0] as RichText).inlineEditor?.focusStart(); + } else { + richTexts[richTexts.length - 1].inlineEditor?.focusEnd(); + } } } + }; + + if (this.children.length === 0) { + this.std.doc.addBlock( + 'affine:paragraph', + { + text: new this.std.doc.Text(), + }, + this.refModel + ); + void this.updateComplete + .then(() => { + focus(); + }) + .catch(noop); + } else { + focus(); } } }; @@ -111,6 +136,12 @@ export class MicrosheetCellContainer extends SignalWatcher( return this.column.readonly$.value; } + get refModel() { + const refId = this.view.cellRefGet(this.rowId, this.column.id); + if (!refId) return; + return this.std.doc.getBlockById(refId as string); + } + private get selectionView() { return this.closest('affine-microsheet-table')?.selectionController; } @@ -153,13 +184,8 @@ export class MicrosheetCellContainer extends SignalWatcher( override render() { if (!this.std) return nothing; - const refId = this.view.cellRefGet(this.rowId, this.column.id); - if (!refId) return; - - const refModel = this.std.doc.getBlockById(refId as string); - - assertExists(refModel); - return html``; + assertExists(this.refModel); + return html``; const renderer = this.column.renderer$.value; if (!renderer) { return; diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/controller/clipboard.ts b/packages/affine/microsheet-data-view/src/view-presets/table/controller/clipboard.ts index cc2de263db05..50b512b36130 100644 --- a/packages/affine/microsheet-data-view/src/view-presets/table/controller/clipboard.ts +++ b/packages/affine/microsheet-data-view/src/view-presets/table/controller/clipboard.ts @@ -2,9 +2,11 @@ import type { UIEventStateContext } from '@blocksuite/block-std'; import type { ReactiveController } from 'lit'; import { toast } from '@blocksuite/affine-components/toast'; +import { Slice } from '@blocksuite/store'; import type { Cell } from '../../../core/view-manager/cell.js'; import type { Row } from '../../../core/view-manager/row.js'; +import type { MicrosheetCellContainer } from '../cell.js'; import type { DataViewTable } from '../table-view.js'; import { @@ -14,8 +16,7 @@ import { type TableViewSelectionWithType, } from '../types.js'; -const BLOCKSUITE_MICROSHEET_TABLE = 'blocksuite/microsheet/table'; -type JsonAreaData = string[][]; +const BLOCKSUITE_MICROSHEET_TABLE = 'blocksuite/microsheet'; const TEXT = 'text/plain'; export class TableClipboardController implements ReactiveController { @@ -25,24 +26,19 @@ export class TableClipboardController implements ReactiveController { ) => { const table = this.host; - const area = getSelectedArea(tableSelection, table); + const area = getSelectedAreaValues(tableSelection, table); if (!area) { return; } - const stringResult = area - .map(row => row.cells.map(cell => cell.stringValue$.value).join('\t')) - .join('\n'); - const jsonResult: JsonAreaData = area.map(row => - row.cells.map(cell => cell.stringValue$.value) - ); const promiseArr: Promise[] = []; area.forEach(row => { - row.cells.forEach(cell => { + row.forEach(cell => { promiseArr.push( (async () => { - const { ref } = cell; - const cellContainerModel = this.std.doc.getBlockById(ref as string); + const cellContainerModel = this.std.doc.getBlockById( + cell.ref as string + ); if (cellContainerModel) { const slice = Slice.fromModels( this.std.doc, @@ -68,43 +64,20 @@ export class TableClipboardController implements ReactiveController { }); }); await Promise.all(promiseArr); - - if (isCut) { - const deleteRows: string[] = []; - for (const row of area) { - if (row.row) { - deleteRows.push(row.row.rowId); - } else { - for (const cell of row.cells) { - cell.valueSet(undefined); - } - } - } - if (deleteRows.length) { - this.props.view.rowDelete(deleteRows); - } - } this.std.clipboard .writeToClipboard(items => { return { ...items, - [TEXT]: stringResult, - [BLOCKSUITE_MICROSHEET_TABLE]: JSON.stringify(jsonResult), + [TEXT]: 'microsheet-copy-block', + [BLOCKSUITE_MICROSHEET_TABLE]: JSON.stringify(area), }; }) .then(() => { - if (area[0]?.row) { - toast( - this.std.host, - `${area.length} row${area.length > 1 ? 's' : ''} copied to clipboard` - ); - } else { - const count = area.flatMap(row => row.cells).length; - toast( - this.std.host, - `${count} cell${count > 1 ? 's' : ''} copied to clipboard` - ); - } + const count = area.flatMap(row => row).length; + toast( + this.std.host, + `${count} cell${count > 1 ? 's' : ''} copied to clipboard` + ); }) .catch(console.error); @@ -131,10 +104,10 @@ export class TableClipboardController implements ReactiveController { } if (tableSelection) { const json = await this.std.clipboard.readFromClipboard(clipboardData); - const dataString = json[BLOCKSUITE_MICROSHEET_TABLE]; - if (!dataString) return; - const jsonAreaData = JSON.parse(dataString) as JsonAreaData; - pasteToCells(view, jsonAreaData, tableSelection); + const copiedValues = json[BLOCKSUITE_MICROSHEET_TABLE]; + if (!copiedValues) return; + const jsonAreaData = JSON.parse(copiedValues) as CopyedSelectionData; + this.pasteToCells(view, jsonAreaData, tableSelection); } else if (this.host.selectionController.focus) { const json = await this.std.clipboard.readFromClipboard(clipboardData); const copiedValues = json[BLOCKSUITE_MICROSHEET_TABLE]; @@ -142,22 +115,26 @@ export class TableClipboardController implements ReactiveController { const copyedSelectionData = JSON.parse( copiedValues ) as CopyedSelectionData; - const rowStartIndex = this.host.selectionController.focus.rowIndex; const columnStartIndex = this.host.selectionController.focus.columnIndex; const rowLength = copyedSelectionData.length; const columnLength = copyedSelectionData[0].length; - - pasteToCells( - data, - view, - copyedSelectionData, - undefined, - rowStartIndex, - columnStartIndex, - rowLength, - columnLength - ); + const tableAreaSelection = TableAreaSelection.create({ + focus: { + rowIndex: rowStartIndex, + columnIndex: columnStartIndex, + }, + rowsSelection: { + start: rowStartIndex, + end: rowStartIndex + rowLength - 1, + }, + columnsSelection: { + start: columnStartIndex, + end: columnStartIndex + columnLength - 1, + }, + isEditing: false, + }); + this.pasteToCells(view, copyedSelectionData, tableAreaSelection); } return true; @@ -179,6 +156,66 @@ export class TableClipboardController implements ReactiveController { host.addController(this); } + private pasteToCells( + table: DataViewTable, + copied: CopyedSelectionData, + tableAreaSelection: TableAreaSelection + ) { + const { view } = table.props; + for ( + let i = 0; + i <= + tableAreaSelection.rowsSelection.end - + tableAreaSelection.rowsSelection.start; + i++ + ) { + for ( + let j = 0; + j <= + tableAreaSelection.columnsSelection.end - + tableAreaSelection.columnsSelection.start; + j++ + ) { + const copyCell = copied?.[i]?.[j]; + if (!copyCell) continue; + const targetContainer = table.selectionController.getCellContainer( + tableAreaSelection.groupKey, + i + tableAreaSelection.rowsSelection.start, + j + tableAreaSelection.columnsSelection.start + ); + const rowId = targetContainer?.dataset.rowId; + const columnId = targetContainer?.dataset.columnId; + if (rowId && columnId) { + const { cellContainerSlice } = copyCell; + const targetCellContainerId = view.cellRefGet( + rowId, + columnId + ) as string; + if (targetCellContainerId) { + const cellContainerBlock = this.std.doc.getBlockById( + targetCellContainerId + ); + if (cellContainerBlock) { + const children = cellContainerBlock.children; + children.forEach(b => { + this.std.doc.deleteBlock(b); + }); + } + (async () => { + await this.std.clipboard.pasteCellSliceSnapshot( + JSON.parse(cellContainerSlice as string)?.snapshot, + this.std.doc, + targetCellContainerId + ); + })() + .then() + .catch(() => {}); + } + } + } + } + } + copy() { const tableSelection = this.host.selectionController.selection; if (!tableSelection) { @@ -220,11 +257,31 @@ export class TableClipboardController implements ReactiveController { if (this.readonly) return false; this._onPaste(ctx).catch(console.error); + return true; }) ); } } - +function getSelectedAreaValues( + selection: TableViewSelection, + table: DataViewTable +): { ref: string; cellContainerSlice: string }[][] { + const view = table.props.view; + const rsl: { ref: string; cellContainerSlice: string }[][] = []; + const values = getSelectedArea(selection, table); + values?.forEach((row, index) => { + const cells = row.cells; + if (!rsl[index]) { + rsl[index] = []; + } + cells.forEach(cell => { + rsl[index].push({ + ref: view.cellRefGet(cell.rowId, cell.propertyId) as string, + }); + }); + }); + return rsl; +} function getSelectedArea( selection: TableViewSelection, table: DataViewTable @@ -282,62 +339,11 @@ type SelectedArea = { cells: Cell[]; }[]; -function getTargetRangeFromSelection( - selection: TableAreaSelection, - data: JsonAreaData -) { - const { rowsSelection, columnsSelection, focus } = selection; - return TableAreaSelection.isFocus(selection) - ? { - row: { - start: focus.rowIndex, - length: data.length, - }, - column: { - start: focus.columnIndex, - length: data[0].length, - }, - } - : { - row: { - start: rowsSelection.start, - length: rowsSelection.end - rowsSelection.start + 1, - }, - column: { - start: columnsSelection.start, - length: columnsSelection.end - columnsSelection.start + 1, - }, - }; -} - -function pasteToCells( - table: DataViewTable, - rows: JsonAreaData, - selection: TableAreaSelection -) { - const srcRowLength = rows.length; - const srcColumnLength = rows[0].length; - const targetRange = getTargetRangeFromSelection(selection, rows); - for (let i = 0; i < targetRange.row.length; i++) { - for (let j = 0; j < targetRange.column.length; j++) { - const rowIndex = targetRange.row.start + i; - const columnIndex = targetRange.column.start + j; - - const srcRowIndex = i % srcRowLength; - const srcColumnIndex = j % srcColumnLength; - const dataString = rows[srcRowIndex][srcColumnIndex]; - - const targetContainer = table.selectionController.getCellContainer( - selection.groupKey, - rowIndex, - columnIndex - ); - const rowId = targetContainer?.dataset.rowId; - const columnId = targetContainer?.dataset.columnId; - - if (rowId && columnId) { - targetContainer?.column.valueSetFromString(rowId, dataString); - } - } - } -} +type CopyedColumn = { + type: string; + value: string; + ref: unknown; + cellContainerSlice?: unknown; + container?: MicrosheetCellContainer; +}; +type CopyedSelectionData = CopyedColumn[][]; diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/controller/drag.ts b/packages/affine/microsheet-data-view/src/view-presets/table/controller/drag.ts index 5d297320f2f6..3c21f89e6612 100644 --- a/packages/affine/microsheet-data-view/src/view-presets/table/controller/drag.ts +++ b/packages/affine/microsheet-data-view/src/view-presets/table/controller/drag.ts @@ -3,10 +3,10 @@ import type { InsertToPosition } from '@blocksuite/affine-shared/utils'; import type { ReactiveController } from 'lit'; +import type { TableRow } from '../row/row.js'; import type { DataViewTable } from '../table-view.js'; import { startDrag } from '../../../core/utils/drag.js'; -import { TableRow } from '../row/row.js'; export class TableDragController implements ReactiveController { dragStart = (row: TableRow, evt: PointerEvent) => { @@ -93,7 +93,7 @@ export class TableDragController implements ReactiveController { | undefined => { const y = evt.y; const tableRect = this.host.getBoundingClientRect(); - const rows = this.host.querySelectorAll('data-view-table-row'); + const rows = this.host.querySelectorAll('microsheet-data-view-table-row'); if (!rows || !tableRect || y < tableRect.top) { return; } @@ -137,16 +137,15 @@ export class TableDragController implements ReactiveController { } this.host.disposables.add( this.host.props.handleEvent('dragStart', context => { - return; const event = context.get('pointerState').raw; const target = event.target; if ( target instanceof Element && this.host.contains(target) && - target.closest('.data-view-table-view-drag-handler') + target.closest('.microsheet-data-view-table-view-drag-handler') ) { event.preventDefault(); - const row = target.closest('data-view-table-row'); + const row = target.closest('microsheet-data-view-table-row'); if (row) { getSelection()?.removeAllRanges(); this.dragStart(row, event); @@ -161,14 +160,9 @@ export class TableDragController implements ReactiveController { const createDragPreview = (row: TableRow, x: number, y: number) => { const div = document.createElement('div'); - const cloneRow = new TableRow(); - cloneRow.view = row.view; - cloneRow.rowIndex = row.rowIndex; - cloneRow.rowId = row.rowId; - cloneRow.dataViewEle = row.dataViewEle; - div.append(cloneRow); + div.append(row.cloneNode(true)); div.className = 'with-data-view-css-variable'; - div.style.width = `${row.getBoundingClientRect().width}px`; + div.style.opacity = '0.8'; div.style.position = 'fixed'; div.style.pointerEvents = 'none'; div.style.backgroundColor = 'var(--affine-background-primary-color)'; diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/controller/selection.ts b/packages/affine/microsheet-data-view/src/view-presets/table/controller/selection.ts index e6748ea5fe74..7f93e5db396f 100644 --- a/packages/affine/microsheet-data-view/src/view-presets/table/controller/selection.ts +++ b/packages/affine/microsheet-data-view/src/view-presets/table/controller/selection.ts @@ -77,6 +77,7 @@ export class TableSelectionController implements ReactiveController { this.clearSelection(); return; } + const selection: TableViewSelectionWithType = { ...data, viewId: this.view.id, @@ -390,11 +391,11 @@ export class TableSelectionController implements ReactiveController { if (!cell) { return; } - const row = cell.closest('data-view-table-row'); + const row = cell.closest('microsheet-data-view-table-row'); const rows = Array.from( row ?.closest('.affine-microsheet-table-container') - ?.querySelectorAll('data-view-table-row') ?? [] + ?.querySelectorAll('microsheet-data-view-table-row') ?? [] ); const cells = Array.from( row?.querySelectorAll('affine-microsheet-cell-container') ?? [] @@ -436,6 +437,7 @@ export class TableSelectionController implements ReactiveController { rowIndex++; } } + rows[rowIndex] ?.querySelectorAll('affine-microsheet-cell-container') ?.item(columnIndex) @@ -513,7 +515,7 @@ export class TableSelectionController implements ReactiveController { getRow(groupKey: string | undefined, rowId: string) { return this.getGroup(groupKey)?.querySelector( - `data-view-table-row[data-row-id='${rowId}']` + `microsheet-data-view-table-row[data-row-id='${rowId}']` ); } @@ -623,7 +625,7 @@ export class TableSelectionController implements ReactiveController { `affine-microsheet-data-view-table-group[data-group-key="${groupKey}"]` ) : this.tableContainer; - return container?.querySelectorAll('data-view-table-row'); + return container?.querySelectorAll('microsheet-data-view-table-row'); } rowSelectionChange({ @@ -659,7 +661,7 @@ export class TableSelectionController implements ReactiveController { const set = new Set(rows); if (!this.tableContainer) return; for (const row of this.tableContainer - ?.querySelectorAll('data-view-table-row') + ?.querySelectorAll('microsheet-data-view-table-row') .values() ?? []) { if (!set.has(row.rowId)) { continue; @@ -1076,6 +1078,8 @@ export class SelectionElement extends WithDisposable(ShadowlessElement) { this.preTask = requestAnimationFrame(() => this.startUpdate(this.selection$.value) ); + } else if (selection?.selectionType === 'row') { + this.updateRowSelectionStyle(selection.rows[0]); } else { this.clearFocusStyle(); this.clearAreaStyle(); @@ -1166,6 +1170,35 @@ export class SelectionElement extends WithDisposable(ShadowlessElement) { dragToFill.style.display = showDragToFillHandle ? 'block' : 'none'; } + updateRowSelectionStyle(row: RowWithGroup) { + const div = this.selectionRef.value; + if (!div) return; + const tableContainer = this.controller.tableContainer; + if (!tableContainer) return; + const tableRect = tableContainer.getBoundingClientRect(); + const rowIndex = this.controller.view.rows$.value?.findIndex( + r => r === row.id + ); + if (rowIndex === -1) return; + const rect = this.controller.getRect( + undefined, + rowIndex, + rowIndex, + 0, + this.controller.view.properties$.value.length - 1 + ); + if (!rect) { + this.clearAreaStyle(); + return; + } + const { left, top, width, height, scale } = rect; + div.style.left = `${left - tableRect.left / scale}px`; + div.style.top = `${top - tableRect.top / scale}px`; + div.style.width = `${width}px`; + div.style.height = `${height}px`; + div.style.display = 'block'; + } + @property({ attribute: false }) accessor controller!: TableSelectionController; } diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/header/column-header.ts b/packages/affine/microsheet-data-view/src/view-presets/table/header/column-header.ts index 9f8bb2fc2e36..3f6c3e947e68 100644 --- a/packages/affine/microsheet-data-view/src/view-presets/table/header/column-header.ts +++ b/packages/affine/microsheet-data-view/src/view-presets/table/header/column-header.ts @@ -2,7 +2,7 @@ import { getScrollContainer } from '@blocksuite/affine-shared/utils'; import { ShadowlessElement } from '@blocksuite/block-std'; import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; import { autoUpdate } from '@floating-ui/dom'; -import { nothing, type TemplateResult } from 'lit'; +import { nothing } from 'lit'; import { property, query } from 'lit/decorators.js'; import { repeat } from 'lit/directives/repeat.js'; import { styleMap } from 'lit/directives/style-map.js'; @@ -84,8 +84,10 @@ export class MicrosheetColumnHeader extends SignalWatcher( override render() { return html` - ${this.renderGroupHeader?.()}
+ ${this.readonly + ? nothing + : html`
`} ${repeat( this.tableViewManager.properties$.value, column => column.id, @@ -94,25 +96,20 @@ export class MicrosheetColumnHeader extends SignalWatcher( width: `${column.width$.value}px`, border: index === 0 ? 'none' : undefined, }); - return index === 0 - ? nothing - : html` `; + return html` `; } )}
`; } - @property({ attribute: false }) - accessor renderGroupHeader: (() => TemplateResult) | undefined; - @query('.scale-div') accessor scaleDiv!: HTMLDivElement; diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/header/microsheet-header-column.ts b/packages/affine/microsheet-data-view/src/view-presets/table/header/microsheet-header-column.ts index 1c14541fc345..43932a796587 100644 --- a/packages/affine/microsheet-data-view/src/view-presets/table/header/microsheet-header-column.ts +++ b/packages/affine/microsheet-data-view/src/view-presets/table/header/microsheet-header-column.ts @@ -230,12 +230,13 @@ export class MicrosheetHeaderColumn extends SignalWatcher( column.tableViewManager = this.tableViewManager; column.column = this.column; column.table = tableContainer; + const dragPreview = createDragPreview( tableContainer, columnHeaderRect.width / scale, headerContainerRect.height / scale, startOffset, - column + this.column.id ); const rectList = getTableGroupRects(tableContainer); const dropPreview = getVerticalIndicator(); @@ -601,18 +602,25 @@ const createDragPreview = ( width: number, height: number, startLeft: number, - content: HTMLElement + id: string ) => { const div = document.createElement('div'); - div.append(content); - // div.style.pointerEvents='none'; + const cells = container.querySelectorAll( + `affine-microsheet-cell-container[data-column-id="${id}"]` + ); + cells.forEach(cell => { + div.append(cell.cloneNode(true)); + }); + div.style.pointerEvents = 'none'; div.style.opacity = '0.8'; div.style.position = 'absolute'; div.style.width = `${width}px`; div.style.height = `${height}px`; div.style.left = `${startLeft}px`; + div.style.opacity = '0.8'; div.style.top = `0px`; div.style.zIndex = '9'; + div.style.backgroundColor = 'var(--affine-background-primary-color)'; container.append(div); return { display(offset: number) { diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/header/styles.ts b/packages/affine/microsheet-data-view/src/view-presets/table/header/styles.ts index ec46e7aaf44e..0da5142282ed 100644 --- a/packages/affine/microsheet-data-view/src/view-presets/table/header/styles.ts +++ b/packages/affine/microsheet-data-view/src/view-presets/table/header/styles.ts @@ -19,11 +19,10 @@ export const styles = css` position: relative; display: flex; flex-direction: row; - /* border-bottom: 1px solid var(--affine-border-color); - border-top: 1px solid var(--affine-border-color); */ box-sizing: border-box; user-select: none; background-color: var(--affine-background-primary-color); + visibility: hidden; } .affine-microsheet-column { @@ -53,13 +52,18 @@ export const styles = css` box-sizing: border-box; position: relative; padding:0; + background-color: #eee; } - .affine-microsheet-column-content:hover, - .affine-microsheet-column-content.edit { - background: blue + .affine-microsheet-column-move:hover { + background-color: blue; } + /* .affine-microsheet-column-content:hover, + .affine-microsheet-column-content.edit { + background-color: blue + } */ + .affine-microsheet-column-content.edit .affine-microsheet-column-text-icon { opacity: 1; } @@ -377,25 +381,38 @@ export const styles = css` display: none; } - .affine-microsheet-column-right-add-icon { - left: unset; - right: -10px; + .affine-microsheet-column-right-add-icon { + left: unset; + right: -10px; } - - .affine-microsheet-column-add-icon:hover svg { + + .affine-microsheet-column-add-icon:hover svg { display: block; - } - - .affine-microsheet-column-add-icon:hover - .affine-microsheet-column-add-not-active-icon { + } + + .affine-microsheet-column-add-icon:hover + .affine-microsheet-column-add-not-active-icon { display: none; - } + } - .affine-microsheet-column-add-not-active-icon { - margin-top: -4px; - width: 4px; - height: 4px; - border-radius: 4px; - background: #ddd; - } + .affine-microsheet-column-add-not-active-icon { + margin-top: -4px; + width: 4px; + height: 4px; + border-radius: 4px; + background: #ddd; + } + + .data-view-table-left-bar{ + padding-left: 16px; + display: flex; + align-items: center; + position: sticky; + left: 0; + width: 24px; + flex-shrink: 0; + visibility: hidden; + background-color: var(--affine-background-primary-color); + z-index: 9; + } `; diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/row/row.ts b/packages/affine/microsheet-data-view/src/view-presets/table/row/row.ts index 8a73ee1c4906..a737b3a3745d 100644 --- a/packages/affine/microsheet-data-view/src/view-presets/table/row/row.ts +++ b/packages/affine/microsheet-data-view/src/view-presets/table/row/row.ts @@ -1,7 +1,11 @@ import { popupTargetFromElement } from '@blocksuite/affine-components/context-menu'; import { type BlockStdScope, ShadowlessElement } from '@blocksuite/block-std'; import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; -import { CenterPeekIcon, MoreHorizontalIcon } from '@blocksuite/icons/lit'; +import { + AddCursorIcon, + CenterPeekIcon, + MoreHorizontalIcon, +} from '@blocksuite/icons/lit'; import { css, nothing } from 'lit'; import { property } from 'lit/decorators.js'; import { repeat } from 'lit/directives/repeat.js'; @@ -66,10 +70,6 @@ export class TableRow extends SignalWatcher(WithDisposable(ShadowlessElement)) { margin-right: 8px; } - .affine-microsheet-block-row .show-on-hover-row { - visibility: hidden; - opacity: 0; - } .affine-microsheet-block-row:hover .show-on-hover-row { visibility: visible; opacity: 1; @@ -110,6 +110,63 @@ export class TableRow extends SignalWatcher(WithDisposable(ShadowlessElement)) { cursor: grab; background-color: var(--affine-background-primary-color); } + .microsheet-data-view-table-left-bar { + padding-left: 16px; + display: flex; + align-items: center; + position: sticky; + left: 0; + width: 24px; + flex-shrink: 0; + visibility: hidden; + z-index: 9; + background-color: var(--affine-background-primary-color); + } + .microsheet-data-view-table-view-drag-handler { + width: 8px; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + cursor: grab; + background-color: #eee; + } + .microsheet-data-view-table-view-drag-handler:hover { + background-color: blue; + } + .microsheet-data-view-table-view-add-icon { + position: absolute; + left: 0px; + top: -10px; + z-index: 9; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + } + .microsheet-data-view-table-view-add-not-active-icon { + margin-left: -2px; + width: 4px; + height: 4px; + border-radius: 4px; + background: #ddd; + } + .microsheet-data-view-table-view-bottom-add-icon { + top: unset; + bottom: -10px; + } + .microsheet-data-view-table-view-add-icon svg { + width: 20px; + height: 20px; + border-radius: 100px; + background: #4949fe; + color: white; + display: none; + } + .microsheet-data-view-table-view-add-icon:hover svg { + display: block; + } `; private _clickDragHandler = () => { @@ -119,6 +176,13 @@ export class TableRow extends SignalWatcher(WithDisposable(ShadowlessElement)) { this.selectionController?.toggleRow(this.rowId, this.groupKey); }; + private rowAdd = (before: boolean) => { + this.view.rowAdd({ + id: this.rowId, + before: before, + }); + }; + contextMenu = (e: MouseEvent) => { if (this.view.readonly$.value) { return; @@ -168,6 +232,35 @@ export class TableRow extends SignalWatcher(WithDisposable(ShadowlessElement)) { protected override render(): unknown { const view = this.view; return html` + ${view.readonly$.value + ? nothing + : html`
+
+
+
+ ${AddCursorIcon()} +
+
+
+ ${AddCursorIcon()} +
+
`} ${repeat( view.properties$.value, v => v.id, @@ -200,44 +293,41 @@ export class TableRow extends SignalWatcher(WithDisposable(ShadowlessElement)) { this.selectionController ); }; - return i === 0 - ? nothing - : html` -
- - -
- ${!column.readonly$.value && - column.view.mainProperties$.value.titleColumn === column.id - ? html`
-
- ${CenterPeekIcon()} -
- ${!view.readonly$.value - ? html`
- ${MoreHorizontalIcon()} -
` - : nothing} -
` - : nothing} - `; + return html` +
+ + +
+ ${!column.readonly$.value && + column.view.mainProperties$.value.titleColumn === column.id + ? html`
+
+ ${CenterPeekIcon()} +
+ ${!view.readonly$.value + ? html`
+ ${MoreHorizontalIcon()} +
` + : nothing} +
` + : nothing} + `; } )} diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/table-view-manager.ts b/packages/affine/microsheet-data-view/src/view-presets/table/table-view-manager.ts index 4f5fa0af6cfb..7e4298fd57d3 100644 --- a/packages/affine/microsheet-data-view/src/view-presets/table/table-view-manager.ts +++ b/packages/affine/microsheet-data-view/src/view-presets/table/table-view-manager.ts @@ -49,9 +49,9 @@ export class TableSingleView extends SingleViewBase { }); detailProperties$ = computed(() => { - return this.propertiesWithoutFilter$.value.filter( - id => this.propertyTypeGet(id) !== 'title' - ); + return this.propertiesWithoutFilter$.value.filter(id => { + return this.propertyTypeGet(id) !== 'title'; + }); }); filter$ = computed(() => { @@ -135,9 +135,7 @@ export class TableSingleView extends SingleViewBase { }); propertyIds$ = computed(() => { - return this.propertiesWithoutFilter$.value.filter( - id => !this.propertyHideGet(id) - ); + return this.detailProperties$.value.filter(id => !this.propertyHideGet(id)); }); readonly$ = computed(() => { diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/table-view.ts b/packages/affine/microsheet-data-view/src/view-presets/table/table-view.ts index 291c11bd85cf..f13ecf30c094 100644 --- a/packages/affine/microsheet-data-view/src/view-presets/table/table-view.ts +++ b/packages/affine/microsheet-data-view/src/view-presets/table/table-view.ts @@ -32,10 +32,8 @@ const styles = css` position: relative; display: flex; flex-direction: column; - overflow: hidden; margin-left: -16px; - padding-top: 16px; - padding-bottom: 10px; + overflow: hidden; } affine-microsheet-table * { @@ -44,8 +42,12 @@ const styles = css` .affine-microsheet-table { overflow-y: auto; + padding-top: 16px; + padding-bottom: 10px; + } + .affine-microsheet-table::-webkit-scrollbar { + display: none; } - .affine-microsheet-block-title-container { display: flex; align-items: center; @@ -59,8 +61,8 @@ const styles = css` width: 100%; padding-bottom: 4px; z-index: 1; - overflow-x: scroll; - overflow-y: hidden; + /* overflow-x: scroll; + overflow-y: hidden; */ } .affine-microsheet-block-table:hover { diff --git a/packages/blocks/src/_common/transformers/middlewares.ts b/packages/blocks/src/_common/transformers/middlewares.ts index 3478db104450..ce09af164c00 100644 --- a/packages/blocks/src/_common/transformers/middlewares.ts +++ b/packages/blocks/src/_common/transformers/middlewares.ts @@ -125,6 +125,12 @@ export const replaceIdMiddleware: JobMiddleware = ({ slots, collection }) => { if (payload.type === 'block') { const { snapshot } = payload; + if ( + snapshot.flavour === 'affine:cell' || + snapshot.flavour === 'affine:row' + ) { + return; + } if (snapshot.flavour === 'affine:page') { const index = snapshot.children.findIndex( c => c.flavour === 'affine:surface' diff --git a/packages/blocks/src/cell-block/cell-block.ts b/packages/blocks/src/cell-block/cell-block.ts index 7ee7bdf201e0..44c433ded78d 100644 --- a/packages/blocks/src/cell-block/cell-block.ts +++ b/packages/blocks/src/cell-block/cell-block.ts @@ -20,12 +20,10 @@ export class CellBlockComponent extends CaptionedBlockComponent< override connectedCallback() { super.connectedCallback(); - this.keymapController.bind(); } override renderBlock() { - console.log('renderCell'); return html`${this.renderChildren(this.model)}`; } } diff --git a/packages/blocks/src/cell-block/keymap-controller.ts b/packages/blocks/src/cell-block/keymap-controller.ts index df5ab7b9f18b..a8e1222bd634 100644 --- a/packages/blocks/src/cell-block/keymap-controller.ts +++ b/packages/blocks/src/cell-block/keymap-controller.ts @@ -325,28 +325,21 @@ export class KeymapController implements ReactiveController { }; private _onSelectAll: UIEventHandler = ctx => { - ctx.get('defaultState').event.preventDefault(); - const selection = this._std.selection; - if (!selection.find('block')) { + const childrenModels = this.host.model.children; + if ( + this._std.selection.filter('block').length === childrenModels.length && + this._std.selection + .filter('block') + .every(block => + childrenModels.some(model => model.id === block.blockId) + ) + ) { return; } - - try { - if (selection.value.length === this.host.model.children.length) return; - } catch (err) { - console.log(err); - } - const blocks: BlockSelection[] = this.host.model.children.map(child => { - return selection.create('block', { - blockId: child.id, - }); - }); - selection.update(selList => { - return selList - .filter(sel => !sel.is('block')) - .concat(blocks); - }); - + const childrenBlocksSelection = this.host.model.children.map(model => + this._std.selection.create('block', { blockId: model.id }) + ); + this._std.selection.setGroup('note', childrenBlocksSelection); return true; }; } diff --git a/packages/blocks/src/microsheet-block/data-source.ts b/packages/blocks/src/microsheet-block/data-source.ts index 5a22c9c9695c..c930b0d328de 100644 --- a/packages/blocks/src/microsheet-block/data-source.ts +++ b/packages/blocks/src/microsheet-block/data-source.ts @@ -268,7 +268,6 @@ export class MicrosheetBlockDataSource extends DataSourceBase { } propertyTypeGet(propertyId: string): string { - return 'rich-text'; if (propertyId === 'type') { return 'image'; } diff --git a/packages/blocks/src/microsheet-block/microsheet-block.ts b/packages/blocks/src/microsheet-block/microsheet-block.ts index 9207d389c1cf..c9b4fcac572e 100644 --- a/packages/blocks/src/microsheet-block/microsheet-block.ts +++ b/packages/blocks/src/microsheet-block/microsheet-block.ts @@ -1,4 +1,5 @@ import type { MicrosheetBlockModel } from '@blocksuite/affine-model'; +import type { DataViewTable } from '@blocksuite/microsheet-data-view/view-presets'; import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption'; import { @@ -31,8 +32,6 @@ import { renderUniLit, uniMap, } from '@blocksuite/microsheet-data-view'; -// eslint-disable-next-line @typescript-eslint/no-restricted-imports -import { TableRowSelection } from '@blocksuite/microsheet-data-view/src/view-presets/table/types.js'; import { widgetPresets } from '@blocksuite/microsheet-data-view/widget-presets'; import { Slice } from '@blocksuite/store'; import { computed, signal } from '@preact/signals-core'; @@ -43,6 +42,8 @@ import type { NoteBlockComponent } from '../note-block/index.js'; import type { MicrosheetOptionsConfig } from './config.js'; import type { MicrosheetBlockService } from './microsheet-service.js'; +// eslint-disable-next-line @typescript-eslint/no-restricted-imports +import { TableRowSelection } from '../../../affine/data-view/src/view-presets/table/types.js'; import { EdgelessRootBlockComponent, type RootService, @@ -63,10 +64,7 @@ export class MicrosheetBlockComponent extends CaptionedBlockComponent< ${unsafeCSS(dataViewCommonStyle('affine-microsheet'))} affine-microsheet { display: block; - border-radius: 8px; background-color: var(--affine-background-primary-color); - padding: 8px; - margin: 8px -8px -8px; } affine-microsheet:hover .affine-microsheet-column-header { @@ -76,6 +74,9 @@ export class MicrosheetBlockComponent extends CaptionedBlockComponent< affine-microsheet:hover .microsheet-data-view-table-left-bar { visibility: visible; } + affine-microsheet:hover .data-view-table-left-bar { + visibility: visible; + } affine-microsheet affine-paragraph .affine-block-component { // margin: 0 !important; @@ -308,6 +309,10 @@ export class MicrosheetBlockComponent extends CaptionedBlockComponent< return this._dataSource; } + get dataViewTableElement() { + return this._DataViewTableElement; + } + get optionsConfig(): MicrosheetOptionsConfig { return { configure: (_model, options) => options, @@ -345,7 +350,7 @@ export class MicrosheetBlockComponent extends CaptionedBlockComponent< this.bindHotKey({ Backspace: () => { const selectionController = - this._DataViewTableElement?.selectionController; + this.dataViewTableElement?.selectionController; const selection = selectionController?.selection; if (!selectionController || !selection) return; const data = this.dataSource; @@ -403,23 +408,23 @@ export class MicrosheetBlockComponent extends CaptionedBlockComponent< }, Tab: context => { const selectionController = - this._DataViewTableElement?.selectionController; + this.dataViewTableElement?.selectionController; if (!selectionController || !selectionController.focus) return; context.get('keyboardState').raw.preventDefault(); - selectionController.focusToCell('right', 'start'); + selectionController.focusToCell('right', 'end'); return true; }, 'Shift-Tab': context => { const selectionController = - this._DataViewTableElement?.selectionController; + this.dataViewTableElement?.selectionController; if (!selectionController) return; context.get('keyboardState').raw.preventDefault(); - selectionController.focusToCell('left', 'start'); + selectionController.focusToCell('left', 'end'); return true; }, ArrowLeft: context => { const selectionController = - this._DataViewTableElement?.selectionController; + this.dataViewTableElement?.selectionController; if (!selectionController) return; if (isInCellStart(this.host.std, true)) { const stop = selectionController.focusToCell('left'); @@ -432,7 +437,7 @@ export class MicrosheetBlockComponent extends CaptionedBlockComponent< }, ArrowRight: context => { const selectionController = - this._DataViewTableElement?.selectionController; + this.dataViewTableElement?.selectionController; if (!selectionController || !selectionController.focus) return; if (isInCellEnd(this.host.std, true)) { const stop = selectionController.focusToCell('right'); @@ -445,7 +450,7 @@ export class MicrosheetBlockComponent extends CaptionedBlockComponent< }, ArrowUp: context => { const selectionController = - this._DataViewTableElement?.selectionController; + this.dataViewTableElement?.selectionController; if (!selectionController || !selectionController.focus) return; if (isInCellStart(this.host.std)) { const { isFirst } = calculateLineNum(this.host.std); @@ -461,7 +466,7 @@ export class MicrosheetBlockComponent extends CaptionedBlockComponent< }, ArrowDown: context => { const selectionController = - this._DataViewTableElement?.selectionController; + this.dataViewTableElement?.selectionController; if (!selectionController || !selectionController.focus) return; if (isInCellEnd(this.host.std)) { const { isLast } = calculateLineNum(this.host.std); @@ -475,6 +480,18 @@ export class MicrosheetBlockComponent extends CaptionedBlockComponent< } return; }, + 'Mod-a': () => { + if ( + this.std.selection.filter('block').length === 1 && + this.std.selection.filter('block')[0].blockId === this.blockId + ) { + return; + } + this.std.selection.setGroup('note', [ + this.std.selection.create('block', { blockId: this.blockId }), + ]); + return true; + }, }) ); } diff --git a/packages/blocks/src/microsheet-block/utils.ts b/packages/blocks/src/microsheet-block/utils.ts index 98a8774795d3..921f01f23f71 100644 --- a/packages/blocks/src/microsheet-block/utils.ts +++ b/packages/blocks/src/microsheet-block/utils.ts @@ -5,6 +5,7 @@ import type { MicrosheetBlockModel, ViewBasicDataType, } from '@blocksuite/affine-model'; +import type { BlockStdScope } from '@blocksuite/block-std'; import type { BlockModel } from '@blocksuite/store'; import { @@ -12,6 +13,7 @@ import { insertPositionToIndex, type InsertToPosition, } from '@blocksuite/affine-shared/utils'; +import { assertExists } from '@blocksuite/global/utils'; export function addProperty( model: MicrosheetBlockModel, diff --git a/packages/blocks/src/note-block/note-block.ts b/packages/blocks/src/note-block/note-block.ts index 2e686fe563a3..8a23c70da5c5 100644 --- a/packages/blocks/src/note-block/note-block.ts +++ b/packages/blocks/src/note-block/note-block.ts @@ -23,6 +23,7 @@ export class NoteBlockComponent extends BlockComponent< } override renderBlock() { + console.log(111, this.std.doc); return html`
diff --git a/packages/blocks/src/root-block/clipboard/adapter.ts b/packages/blocks/src/root-block/clipboard/adapter.ts index 342282a17fab..7daf532dc458 100644 --- a/packages/blocks/src/root-block/clipboard/adapter.ts +++ b/packages/blocks/src/root-block/clipboard/adapter.ts @@ -1,6 +1,8 @@ +import type { CellBlockModel } from '@blocksuite/affine-model'; import type { BlockSnapshot, DocSnapshot, + DocSnapshot, FromBlockSnapshotPayload, FromBlockSnapshotResult, FromDocSnapshotPayload, @@ -16,6 +18,7 @@ import type { import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; import { assertExists } from '@blocksuite/global/utils'; import { BaseAdapter } from '@blocksuite/store'; +import { nanoid } from 'nanoid'; import { decodeClipboardBlobs, encodeClipboardBlobs } from './utils.js'; @@ -91,3 +94,254 @@ export class ClipboardAdapter extends BaseAdapter { return Promise.resolve(snapshot); } } + +export class MicrosheetAdapter extends BaseAdapter { + static MIME = 'blocksuite/microsheet'; + + override fromBlockSnapshot(): + | Promise> + | FromBlockSnapshotResult { + throw new Error('Method not implemented.'); + } + + override fromDocSnapshot(): + | Promise> + | FromDocSnapshotResult { + throw new Error('Method not implemented.'); + } + + override fromSliceSnapshot( + payload: FromSliceSnapshotPayload + ): + | Promise> + | FromSliceSnapshotResult { + return payload; + } + + override toBlockSnapshot(): Promise | BlockSnapshot { + throw new Error('Method not implemented.'); + } + + override toDocSnapshot(): Promise | DocSnapshot { + throw new Error('Method not implemented.'); + } + + override toSliceSnapshot( + payload: ToSliceSnapshotPayload + ): Promise | SliceSnapshot | null { + let copiedCells = []; + try { + copiedCells = JSON.parse(payload.file); + } catch (err) { + console.error(err); + } + if (copiedCells.length === 0) return null; + const microsheetSnapshotContent = new MicrosheetSnapshotContent( + copiedCells + ); + const snapshot: SliceSnapshot = { + type: 'slice', + pageVersion: payload.pageVersion, + workspaceVersion: payload.workspaceVersion, + workspaceId: payload.workspaceId, + pageId: payload.pageId, + content: [microsheetSnapshotContent.toSnapshotContent()], + }; + + return snapshot; + } +} + +interface CopiedCellItem { + cellContainerSlice: string; +} + +interface PropCellItem { + columnId: string; + value: string; + ref: string; +} + +type PropCells = Record>; + +interface PropColumnItem { + type: 'title' | 'rich-text'; + name: 'Title' | 'content'; + data: {}; + id: string; +} + +class MicrosheetSnapshotContent { + cells: PropCells = {}; + + colCount: number; + + columns: PropColumnItem[] = []; + + copiedCells: CopiedCellItem[][]; + + rowCount: number; + + rows: RowSnapshot[] = []; + + titleColumnsId: string; + + constructor(copiedCells: CopiedCellItem[][]) { + this.copiedCells = copiedCells; + this.titleColumnsId = nanoid(); + this.rowCount = copiedCells.length; + this.colCount = copiedCells[0]?.length || 0; + + this.init(); + } + + private addColumn(props: Partial = {}) { + const newColumn: PropColumnItem = { + type: 'rich-text', + name: 'Title', + data: {}, + id: nanoid(), + ...props, + }; + this.columns.push(newColumn); + return newColumn.id; + } + + private addRow() { + const row = new RowSnapshot(this); + this.rows.push(row); + return row; + } + + private getCellContent(i: number, j: number) { + const item = this.copiedCells[i][j]; + try { + const snapshot = JSON.parse(item.cellContainerSlice) + ?.snapshot as SliceSnapshot; + return snapshot.content; + } catch (err) { + console.error(err); + return []; + } + } + + private getProps() { + return { + views: [ + { + id: nanoid(), + name: 'Table View', + mode: 'table', + columns: [], + filter: { + type: 'group', + op: 'and', + conditions: [], + }, + header: { + titleColumn: this.titleColumnsId, + iconColumn: 'type', + }, + }, + ], + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + cells: this.cells, + columns: this.columns, + }; + } + + private init() { + this.addColumn({ id: this.titleColumnsId, type: 'title' }); + + const contentColumnIds = []; + for (let i = 0; i < this.colCount; i++) { + contentColumnIds.push(this.addColumn()); + } + + for (let i = 0; i < this.rowCount; i++) { + const row = this.addRow(); + for (let j = 0; j < this.colCount; j++) { + const cell = row.addCell(contentColumnIds[j]); + cell.addChildren(this.getCellContent(i, j)); + } + } + } + + toSnapshotContent() { + return { + type: 'block', + id: nanoid(), + flavour: 'affine:microsheet', + version: 3, + props: this.getProps(), + children: this.rows.map(row => row.toSnapshotContent()), + } as SliceSnapshot['content'][number]; + } +} + +class RowSnapshot { + cells: CellSnapshot[] = []; + + ctx: MicrosheetSnapshotContent; + + id: string; + + constructor(ctx: MicrosheetSnapshotContent) { + this.ctx = ctx; + this.id = nanoid(); + } + + addCell(columnId: string) { + const cell = new CellSnapshot(); + this.cells.push(cell); + + if (!this.ctx.cells[this.id]) { + this.ctx.cells[this.id] = {}; + } + this.ctx.cells[this.id][columnId] = { + columnId, + value: '', + ref: cell.id, + }; + return cell; + } + + toSnapshotContent() { + return { + type: 'block', + id: this.id, + flavour: 'affine:row', + version: 1, + props: {}, + children: this.cells.map(cell => cell.toSnapshotContent()), + }; + } +} + +class CellSnapshot { + children: CellBlockModel[] = []; + + id: string; + + constructor() { + this.id = nanoid(); + } + + addChildren(items: CellBlockModel[]) { + this.children.push(...items); + } + + toSnapshotContent() { + return { + type: 'block', + id: this.id, + flavour: 'affine:cell', + version: 1, + props: {}, + children: this.children, + }; + } +} diff --git a/packages/blocks/src/root-block/clipboard/index.ts b/packages/blocks/src/root-block/clipboard/index.ts index 36f059768694..eb9609b000c1 100644 --- a/packages/blocks/src/root-block/clipboard/index.ts +++ b/packages/blocks/src/root-block/clipboard/index.ts @@ -15,7 +15,7 @@ import { replaceIdMiddleware, titleMiddleware, } from '../../_common/transformers/middlewares.js'; -import { ClipboardAdapter } from './adapter.js'; +import { ClipboardAdapter, MicrosheetAdapter } from './adapter.js'; import { copyMiddleware, pasteMiddleware } from './middlewares/index.js'; export class PageClipboard { @@ -36,6 +36,11 @@ export class PageClipboard { ClipboardAdapter, 100 ); + this._std.clipboard.registerAdapter( + MicrosheetAdapter.MIME, + MicrosheetAdapter, + 98 + ); this._std.clipboard.registerAdapter( 'text/_notion-text-production', NotionTextAdapter, diff --git a/packages/blocks/src/root-block/widgets/slash-menu/utils.ts b/packages/blocks/src/root-block/widgets/slash-menu/utils.ts index db14bdd023a5..61f933e9db99 100644 --- a/packages/blocks/src/root-block/widgets/slash-menu/utils.ts +++ b/packages/blocks/src/root-block/widgets/slash-menu/utils.ts @@ -80,6 +80,14 @@ export function getFirstNotDividerItem( return firstItem ?? null; } +export function insideDatabase(model: BlockModel) { + return isInsideBlockByFlavour(model.doc, model, 'affine:database'); +} + +export function insideMicrosheet(model: BlockModel) { + return isInsideBlockByFlavour(model.doc, model, 'affine:microsheet'); +} + export function insideEdgelessText(model: BlockModel) { return isInsideBlockByFlavour(model.doc, model, 'affine:edgeless-text'); } diff --git a/packages/framework/block-std/src/clipboard/index.ts b/packages/framework/block-std/src/clipboard/index.ts index 425b5a09ef19..ef3aa6675bb8 100644 --- a/packages/framework/block-std/src/clipboard/index.ts +++ b/packages/framework/block-std/src/clipboard/index.ts @@ -4,6 +4,7 @@ import type { Doc, JobMiddleware, Slice, + SliceSnapshot, } from '@blocksuite/store'; import type { RootContentMap } from 'hast'; @@ -245,6 +246,16 @@ export class Clipboard extends LifeCycleWatcher { return this._getJob().snapshotToBlock(snapshot, doc, parent, index); }; + pasteCellSliceSnapshot = async ( + snapshot: SliceSnapshot, + doc: Doc, + parent?: string, + index?: number + ) => { + const job = this._getJob(); + return job.snapshotToCellSlice(snapshot, doc, parent, index); + }; + registerAdapter = ( mimeType: string, adapter: AdapterConstructor, diff --git a/packages/framework/block-std/src/view/decorators/required.ts b/packages/framework/block-std/src/view/decorators/required.ts index 436fc8d7abad..033f2be35ef7 100644 --- a/packages/framework/block-std/src/view/decorators/required.ts +++ b/packages/framework/block-std/src/view/decorators/required.ts @@ -27,6 +27,13 @@ function validatePropTypes>( ) { for (const [propName, validator] of Object.entries(propTypes)) { const key = propName as keyof T; + if ( + (instance.flavour === 'affine:row' || + instance.flavour === 'affine:cell') && + propName === 'widgets' + ) { + continue; + } if (instance[key] === undefined) { throw new BlockSuiteError( ErrorCode.DefaultRuntimeError, diff --git a/packages/framework/store/src/adapter/base.ts b/packages/framework/store/src/adapter/base.ts index 7b1a81e55a85..a6fd39d355b2 100644 --- a/packages/framework/store/src/adapter/base.ts +++ b/packages/framework/store/src/adapter/base.ts @@ -119,7 +119,7 @@ export abstract class BaseAdapter { try { const sliceSnapshot = await this.job.sliceToSnapshot(slice); if (!sliceSnapshot) return; - wrapFakeNote(sliceSnapshot); + // wrapFakeNote(sliceSnapshot); return await this.fromSliceSnapshot({ snapshot: sliceSnapshot, assets: this.job.assetsManager, diff --git a/packages/framework/store/src/transformer/job.ts b/packages/framework/store/src/transformer/job.ts index f6c9081d860a..a9964716f080 100644 --- a/packages/framework/store/src/transformer/job.ts +++ b/packages/framework/store/src/transformer/job.ts @@ -211,6 +211,64 @@ export class Job { } }; + snapshotToCellSlice = async ( + snapshot: SliceSnapshot, + doc: Doc, + parent?: string, + index?: number + ): Promise => { + SliceSnapshotSchema.parse(snapshot); + try { + const { content, pageVersion, workspaceVersion, workspaceId, pageId } = + snapshot; + + // Create a temporary root snapshot to encompass all content blocks + const tmpRootSnapshot: BlockSnapshot = { + id: 'temporary-root', + flavour: 'affine:cell', + props: {}, + type: 'block', + children: content, + }; + + for (const block of content) { + this._triggerBeforeImportEvent(block, parent, index); + } + const flatSnapshots: FlatSnapshot[] = []; + this._flattenSnapshot(tmpRootSnapshot, flatSnapshots, parent, index); + + const blockTree = await this._convertFlatSnapshots(flatSnapshots); + + await this._insertBlockTree(blockTree.children, doc, parent, index); + + const contentBlocks = blockTree.children + .map(tree => { + return doc.getBlockById(tree.draft.id); + }) + .filter(Boolean) as DraftModel[]; + + const slice = new Slice({ + content: contentBlocks, + pageVersion, + workspaceVersion, + workspaceId, + pageId, + }); + + this._slots.afterImport.emit({ + type: 'slice', + snapshot, + slice, + }); + + return slice; + } catch (error) { + console.error(`Error when transforming snapshot to slice:`); + console.error(error); + return; + } + }; + snapshotToDoc = async (snapshot: DocSnapshot): Promise => { try { this._slots.beforeImport.emit({ @@ -291,13 +349,14 @@ export class Job { } const flatSnapshots: FlatSnapshot[] = []; this._flattenSnapshot(tmpRootSnapshot, flatSnapshots, parent, index); - const blockTree = await this._convertFlatSnapshots(flatSnapshots); await this._insertBlockTree(blockTree.children, doc, parent, index); const contentBlocks = blockTree.children - .map(tree => doc.getBlockById(tree.draft.id)) + .map(tree => { + return doc.getBlockById(tree.draft.id); + }) .filter(Boolean) as DraftModel[]; const slice = new Slice({ @@ -449,6 +508,7 @@ export class Job { return { id: flat.snapshot.id, flavour: flat.snapshot.flavour, + // children: flat.snapshot.children, children: [], ...props, } as DraftModel; From 38d57e264a2318e6feeddefe3b364f92c6c590bf Mon Sep 17 00:00:00 2001 From: "caojiafu@cvte.com" Date: Thu, 7 Nov 2024 11:00:07 +0800 Subject: [PATCH 05/13] feat(blocks): remove kanbanview --- .../src/core/common/detail/selection.ts | 24 +-- .../src/core/common/group-by/setting.ts | 23 +-- .../src/core/common/selection.ts | 30 +-- .../microsheet-data-view/src/core/types.ts | 5 +- .../src/core/view-manager/view-manager.ts | 25 --- .../microsheet-data-view/src/effects.ts | 17 +- .../src/view-presets/convert.ts | 21 --- .../src/view-presets/index.ts | 4 - .../table/header/microsheet-header-column.ts | 172 +----------------- .../src/view-presets/table/row/row.ts | 34 +++- .../tools/presets/search/search.ts | 3 +- .../presets/view-options/view-options.ts | 16 +- .../affine/model/src/blocks/database/types.ts | 1 + .../model/src/blocks/microsheet/index.ts | 1 - .../model/src/blocks/microsheet/types.ts | 39 ++-- .../src/microsheet-block/data-source.ts | 19 +- .../detail-panel/block-renderer.ts | 7 +- .../src/microsheet-block/microsheet-block.ts | 5 - packages/blocks/src/microsheet-block/utils.ts | 3 +- .../src/microsheet-block/views/index.ts | 11 +- .../data-view-block.ts | 5 - .../microsheet-data-view-block/views/index.ts | 5 +- 22 files changed, 89 insertions(+), 381 deletions(-) delete mode 100644 packages/affine/microsheet-data-view/src/view-presets/convert.ts diff --git a/packages/affine/microsheet-data-view/src/core/common/detail/selection.ts b/packages/affine/microsheet-data-view/src/core/common/detail/selection.ts index 52b08c574b6d..8f117165f7ea 100644 --- a/packages/affine/microsheet-data-view/src/core/common/detail/selection.ts +++ b/packages/affine/microsheet-data-view/src/core/common/detail/selection.ts @@ -1,8 +1,5 @@ -import type { KanbanCard } from '../../../view-presets/kanban/card.js'; -import type { KanbanCardSelection } from '../../../view-presets/kanban/types.js'; import type { RecordDetail } from './detail.js'; -import { KanbanCell } from '../../../view-presets/kanban/cell.js'; import { RecordField } from './field.js'; type DetailViewSelection = { @@ -101,14 +98,7 @@ export class DetailSelection { if (!selection || selection?.isEditing) { return; } - const nextContainer = - this.getFocusCellContainer(selection)?.nextElementSibling; - if (nextContainer instanceof KanbanCell) { - this.selection = { - propertyId: nextContainer.column.id, - isEditing: false, - }; - } + this.getFocusCellContainer(selection)?.nextElementSibling; } focusFirstCell() { @@ -143,16 +133,4 @@ export class DetailSelection { `affine-microsheet-data-view-record-field[data-column-id="${selection.propertyId}"]` ) as RecordField | undefined; } - - getSelectCard(selection: KanbanCardSelection) { - const { groupKey, cardId } = selection.cards[0]; - - return this.viewEle - .querySelector( - `affine-microsheet-data-view-kanban-group[data-key="${groupKey}"]` - ) - ?.querySelector( - `affine-microsheet-data-view-kanban-card[data-card-id="${cardId}"]` - ) as KanbanCard | undefined; - } } diff --git a/packages/affine/microsheet-data-view/src/core/common/group-by/setting.ts b/packages/affine/microsheet-data-view/src/core/common/group-by/setting.ts index 1b6811af7bba..544dd7f8c9db 100644 --- a/packages/affine/microsheet-data-view/src/core/common/group-by/setting.ts +++ b/packages/affine/microsheet-data-view/src/core/common/group-by/setting.ts @@ -15,14 +15,10 @@ import { property, query } from 'lit/decorators.js'; import { repeat } from 'lit/directives/repeat.js'; import Sortable from 'sortablejs'; -import type { - KanbanViewData, - TableViewData, -} from '../../../view-presets/index.js'; +import type { TableViewData } from '../../../view-presets/index.js'; import type { SingleView } from '../../view-manager/single-view.js'; import type { GroupRenderProps } from './types.js'; -import { KanbanSingleView } from '../../../view-presets/kanban/kanban-view-manager.js'; import { TableSingleView } from '../../../view-presets/table/table-view-manager.js'; import { renderUniLit } from '../../utils/uni-component/uni-component.js'; import { dataViewCssVariable } from '../css-variable.js'; @@ -149,11 +145,11 @@ export class GroupSetting extends SignalWatcher( accessor groupContainer!: HTMLElement; @property({ attribute: false }) - accessor view!: TableSingleView | KanbanSingleView; + accessor view!: TableSingleView; } export const selectGroupByProperty = ( - view: SingleView, + view: SingleView, ops?: { onSelect?: (id?: string) => void; onClose?: () => void; @@ -183,10 +179,7 @@ export const selectGroupByProperty = ( .uni="${property.icon}" >`, select: () => { - if ( - view instanceof TableSingleView || - view instanceof KanbanSingleView - ) { + if (view instanceof TableSingleView) { view.changeGroup(id); ops?.onSelect?.(id); } @@ -197,9 +190,7 @@ export const selectGroupByProperty = ( items: [ menu.action({ prefix: DeleteIcon(), - hide: () => - view instanceof KanbanSingleView || - view.data$.value?.groupBy == null, + hide: () => view.data$.value?.groupBy == null, class: 'delete-item', name: 'Remove Grouping', select: () => { @@ -216,7 +207,7 @@ export const selectGroupByProperty = ( }; export const popSelectGroupByProperty = ( target: PopupTarget, - view: SingleView, + view: SingleView, ops?: { onSelect?: () => void; onClose?: () => void; @@ -229,7 +220,7 @@ export const popSelectGroupByProperty = ( }; export const popGroupSetting = ( target: PopupTarget, - view: SingleView, + view: SingleView, onBack: () => void ) => { const groupBy = view.data$.value?.groupBy; diff --git a/packages/affine/microsheet-data-view/src/core/common/selection.ts b/packages/affine/microsheet-data-view/src/core/common/selection.ts index 8b301040c88d..94ec5102aff3 100644 --- a/packages/affine/microsheet-data-view/src/core/common/selection.ts +++ b/packages/affine/microsheet-data-view/src/core/common/selection.ts @@ -32,37 +32,9 @@ const TableViewSelectionSchema = z.union([ }), ]); -const KanbanCellSelectionSchema = z.object({ - selectionType: z.literal('cell'), - groupKey: z.string(), - cardId: z.string(), - columnId: z.string(), - isEditing: z.boolean(), -}); - -const KanbanCardSelectionSchema = z.object({ - selectionType: z.literal('card'), - cards: z.array( - z.object({ - groupKey: z.string(), - cardId: z.string(), - }) - ), -}); - -const KanbanGroupSelectionSchema = z.object({ - selectionType: z.literal('group'), - groupKeys: z.array(z.string()), -}); - const MicrosheetSelectionSchema = z.object({ blockId: z.string(), - viewSelection: z.union([ - TableViewSelectionSchema, - KanbanCellSelectionSchema, - KanbanCardSelectionSchema, - KanbanGroupSelectionSchema, - ]), + viewSelection: z.union([TableViewSelectionSchema]), }); export class MicrosheetSelection extends BaseSelection { diff --git a/packages/affine/microsheet-data-view/src/core/types.ts b/packages/affine/microsheet-data-view/src/core/types.ts index ce38e5ddaf1b..00c36e4abd23 100644 --- a/packages/affine/microsheet-data-view/src/core/types.ts +++ b/packages/affine/microsheet-data-view/src/core/types.ts @@ -1,9 +1,6 @@ -import type { KanbanViewSelectionWithType } from '../view-presets/kanban/types.js'; import type { TableViewSelectionWithType } from '../view-presets/table/types.js'; -export type DataViewSelection = - | TableViewSelectionWithType - | KanbanViewSelectionWithType; +export type DataViewSelection = TableViewSelectionWithType; export type GetDataViewSelection< K extends DataViewSelection['type'], T = DataViewSelection, diff --git a/packages/affine/microsheet-data-view/src/core/view-manager/view-manager.ts b/packages/affine/microsheet-data-view/src/core/view-manager/view-manager.ts index 4981934fe1dd..236e61e0eeed 100644 --- a/packages/affine/microsheet-data-view/src/core/view-manager/view-manager.ts +++ b/packages/affine/microsheet-data-view/src/core/view-manager/view-manager.ts @@ -34,8 +34,6 @@ export interface ViewManager { viewDataGet(id: string): DataViewDataType | undefined; moveTo(id: string, position: InsertToPosition): void; - - viewChangeType(id: string, type: string): void; } export class ViewManagerBase implements ViewManager { @@ -84,29 +82,6 @@ export class ViewManagerBase implements ViewManager { return id; } - viewChangeType(id: string, type: string): void { - const from = this.viewGet(id).type; - const meta = this.dataSource.viewMetaGet(type); - this.dataSource.viewDataUpdate(id, old => { - let data = { - ...meta.model.defaultData(this), - id: old.id, - name: old.name, - mode: type, - }; - const convertFunction = this.dataSource.viewConverts.find( - v => v.from === from && v.to === type - ); - if (convertFunction) { - data = { - ...data, - ...convertFunction.convert(old), - }; - } - return data; - }); - } - viewDataGet(id: string): DataViewDataType | undefined { return this.dataSource.viewDataGet(id); } diff --git a/packages/affine/microsheet-data-view/src/effects.ts b/packages/affine/microsheet-data-view/src/effects.ts index 640eb2b02bb0..f4c6b433d6e4 100644 --- a/packages/affine/microsheet-data-view/src/effects.ts +++ b/packages/affine/microsheet-data-view/src/effects.ts @@ -54,11 +54,7 @@ import { TextCell, TextCellEditing, } from './property-presets/text/cell-renderer.js'; -import { DataViewKanban, DataViewTable } from './view-presets/index.js'; -import { KanbanCard } from './view-presets/kanban/card.js'; -import { KanbanCell } from './view-presets/kanban/cell.js'; -import { KanbanGroup } from './view-presets/kanban/group.js'; -import { KanbanHeader } from './view-presets/kanban/header.js'; +import { DataViewTable } from './view-presets/index.js'; import { MicrosheetCellContainer } from './view-presets/table/cell.js'; import { DragToFillElement } from './view-presets/table/controller/drag-to-fill.js'; import { SelectionElement } from './view-presets/table/controller/selection.js'; @@ -147,13 +143,11 @@ export function effects() { 'microsheet-data-view-group-title-string-view', StringGroupView ); - customElements.define('affine-microsheet-data-view-kanban-card', KanbanCard); customElements.define('microsheet-filter-bar', FilterBar); customElements.define( 'microsheet-data-view-group-title-number-view', NumberGroupView ); - customElements.define('affine-microsheet-data-view-kanban-cell', KanbanCell); customElements.define('affine-microsheet-lit-icon', AffineLitIcon); customElements.define( 'microsheet-filter-condition-view', @@ -208,10 +202,6 @@ export function effects() { 'affine-microsheet-new-record-preview', NewRecordPreview ); - customElements.define( - 'affine-microsheet-data-view-kanban-group', - KanbanGroup - ); customElements.define( 'microsheet-data-view-header-tools-filter', DataViewHeaderToolsFilter @@ -220,11 +210,6 @@ export function effects() { 'microsheet-data-view-header-tools-view-options', DataViewHeaderToolsViewOptions ); - customElements.define('affine-microsheet-data-view-kanban', DataViewKanban); - customElements.define( - 'affine-microsheet-data-view-kanban-header', - KanbanHeader - ); customElements.define('microsheet-variable-ref-view', VariableRefView); customElements.define( 'affine-microsheet-data-view-record-detail', diff --git a/packages/affine/microsheet-data-view/src/view-presets/convert.ts b/packages/affine/microsheet-data-view/src/view-presets/convert.ts deleted file mode 100644 index 19e57fa2108a..000000000000 --- a/packages/affine/microsheet-data-view/src/view-presets/convert.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { createViewConvert } from '../core/view/convert.js'; -import { kanbanViewModel } from './kanban/index.js'; -import { tableViewModel } from './table/index.js'; - -export const viewConverts = [ - createViewConvert(tableViewModel, kanbanViewModel, data => { - if (data.groupBy) { - return { - filter: data.filter, - groupBy: data.groupBy, - }; - } - return { - filter: data.filter, - }; - }), - createViewConvert(kanbanViewModel, tableViewModel, data => ({ - filter: data.filter, - groupBy: data.groupBy, - })), -]; diff --git a/packages/affine/microsheet-data-view/src/view-presets/index.ts b/packages/affine/microsheet-data-view/src/view-presets/index.ts index b4b46ce58b80..211281f14539 100644 --- a/packages/affine/microsheet-data-view/src/view-presets/index.ts +++ b/packages/affine/microsheet-data-view/src/view-presets/index.ts @@ -1,11 +1,7 @@ -import { kanbanViewMeta } from './kanban/index.js'; import { tableViewMeta } from './table/index.js'; -export * from './convert.js'; -export * from './kanban/index.js'; export * from './table/index.js'; export const viewPresets = { tableViewMeta: tableViewMeta, - kanbanViewMeta: kanbanViewMeta, }; diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/header/microsheet-header-column.ts b/packages/affine/microsheet-data-view/src/view-presets/table/header/microsheet-header-column.ts index 43932a796587..d191292126d4 100644 --- a/packages/affine/microsheet-data-view/src/view-presets/table/header/microsheet-header-column.ts +++ b/packages/affine/microsheet-data-view/src/view-presets/table/header/microsheet-header-column.ts @@ -1,6 +1,5 @@ import { menu, - type MenuConfig, popMenu, popupTargetFromElement, } from '@blocksuite/affine-components/context-menu'; @@ -10,16 +9,7 @@ import { } from '@blocksuite/affine-shared/utils'; import { ShadowlessElement } from '@blocksuite/block-std'; import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; -import { - AddCursorIcon, - DeleteIcon, - DuplicateIcon, - InsertLeftIcon, - InsertRightIcon, - MoveLeftIcon, - MoveRightIcon, - ViewIcon, -} from '@blocksuite/icons/lit'; +import { AddCursorIcon, DeleteIcon } from '@blocksuite/icons/lit'; import { css } from 'lit'; import { property } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; @@ -27,16 +17,12 @@ import { createRef, ref } from 'lit/directives/ref.js'; import { styleMap } from 'lit/directives/style-map.js'; import { html } from 'lit/static-html.js'; -import type { Property } from '../../../core/view-manager/property.js'; -import type { NumberPropertyDataType } from '../../../property-presets/index.js'; import type { TableColumn, TableSingleView } from '../table-view-manager.js'; -import { inputConfig, typeConfig } from '../../../core/common/property-menu.js'; import { renderUniLit } from '../../../core/index.js'; import { startDrag } from '../../../core/utils/drag.js'; import { autoScrollOnBoundary } from '../../../core/utils/frame-loop.js'; import { getResultInRange } from '../../../core/utils/utils.js'; -import { numberFormats } from '../../../property-presets/number/utils/formats.js'; import { DEFAULT_COLUMN_TITLE_HEIGHT } from '../consts.js'; import { getTableContainer } from '../types.js'; import { DataViewColumnPreview } from './column-renderer.js'; @@ -316,160 +302,11 @@ export class MicrosheetHeaderColumn extends SignalWatcher( } private popMenu(ele?: HTMLElement) { - const enableNumberFormatting = - this.tableViewManager.featureFlags$.value.enable_number_formatting; - popMenu(popupTargetFromElement(ele ?? this), { options: { items: [ - inputConfig(this.column), - typeConfig(this.column), - // Number format begin - ...(enableNumberFormatting - ? [ - menu.subMenu({ - name: 'Number Format', - hide: () => - !this.column.dataUpdate || - this.column.type$.value !== 'number', - options: { - items: [ - numberFormatConfig(this.column), - ...numberFormats.map(format => { - const data = ( - this.column as Property< - number, - NumberPropertyDataType - > - ).data$.value; - return menu.action({ - isSelected: data.format === format.type, - prefix: html`${format.symbol}`, - name: format.label, - select: () => { - if (data.format === format.type) return; - this.column.dataUpdate(() => ({ - format: format.type, - })); - }, - }); - }), - ], - }, - }), - ] - : []), - // Number format end - menu.group({ - items: [ - menu.action({ - name: 'Hide In View', - prefix: ViewIcon(), - hide: () => - this.column.hide$.value || - this.column.type$.value === 'title', - select: () => { - this.column.hideSet(true); - }, - }), - ], - }), - menu.group({ - items: [ - menu.action({ - name: 'Insert Left Column', - prefix: InsertLeftIcon(), - select: () => { - this.tableViewManager.propertyAdd({ - id: this.column.id, - before: true, - }); - Promise.resolve() - .then(() => { - const pre = this.previousElementSibling; - if (pre instanceof MicrosheetHeaderColumn) { - pre.editTitle(); - pre.scrollIntoView({ - inline: 'nearest', - block: 'nearest', - }); - } - }) - .catch(console.error); - }, - }), - menu.action({ - name: 'Insert Right Column', - prefix: InsertRightIcon(), - select: () => { - this.tableViewManager.propertyAdd({ - id: this.column.id, - before: false, - }); - Promise.resolve() - .then(() => { - const next = this.nextElementSibling; - if (next instanceof MicrosheetHeaderColumn) { - next.editTitle(); - next.scrollIntoView({ - inline: 'nearest', - block: 'nearest', - }); - } - }) - .catch(console.error); - }, - }), - menu.action({ - name: 'Move Left', - prefix: MoveLeftIcon(), - hide: () => this.column.isFirst, - select: () => { - const preId = this.tableViewManager.propertyPreGet( - this.column.id - )?.id; - if (!preId) { - return; - } - this.tableViewManager.propertyMove(this.column.id, { - id: preId, - before: true, - }); - }, - }), - menu.action({ - name: 'Move Right', - prefix: MoveRightIcon(), - hide: () => this.column.isLast, - select: () => { - const nextId = this.tableViewManager.propertyNextGet( - this.column.id - )?.id; - if (!nextId) { - return; - } - this.tableViewManager.propertyMove(this.column.id, { - id: nextId, - before: false, - }); - }, - }), - ], - }), menu.group({ items: [ - menu.action({ - name: 'Duplicate', - prefix: DuplicateIcon(), - hide: () => - !this.column.duplicate || this.column.type$.value === 'title', - select: () => { - this.column.duplicate?.(); - }, - }), menu.action({ name: 'Delete', prefix: DeleteIcon(), @@ -632,13 +469,6 @@ const createDragPreview = ( }; }; -function numberFormatConfig(column: Property): MenuConfig { - return () => - html` `; -} - declare global { interface HTMLElementTagNameMap { 'affine-microsheet-header-column': MicrosheetHeaderColumn; diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/row/row.ts b/packages/affine/microsheet-data-view/src/view-presets/table/row/row.ts index a737b3a3745d..8221961771b2 100644 --- a/packages/affine/microsheet-data-view/src/view-presets/table/row/row.ts +++ b/packages/affine/microsheet-data-view/src/view-presets/table/row/row.ts @@ -1,9 +1,14 @@ -import { popupTargetFromElement } from '@blocksuite/affine-components/context-menu'; +import { + menu, + popMenu, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; import { type BlockStdScope, ShadowlessElement } from '@blocksuite/block-std'; import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; import { AddCursorIcon, CenterPeekIcon, + DeleteIcon, MoreHorizontalIcon, } from '@blocksuite/icons/lit'; import { css, nothing } from 'lit'; @@ -169,11 +174,12 @@ export class TableRow extends SignalWatcher(WithDisposable(ShadowlessElement)) { } `; - private _clickDragHandler = () => { + private _clickDragHandler = (e: MouseEvent) => { if (this.view.readonly$.value) { return; } this.selectionController?.toggleRow(this.rowId, this.groupKey); + this.popMenu(e.currentTarget as HTMLElement); }; private rowAdd = (before: boolean) => { @@ -222,6 +228,30 @@ export class TableRow extends SignalWatcher(WithDisposable(ShadowlessElement)) { return this.closest('affine-microsheet-table')?.selectionController; } + private popMenu(ele?: HTMLElement) { + popMenu(popupTargetFromElement(ele ?? this), { + options: { + items: [ + menu.group({ + items: [ + menu.action({ + name: 'Delete', + prefix: DeleteIcon(), + select: () => { + const selection = this.selectionController; + if (selection) { + selection.deleteRow(this.rowId); + } + }, + class: 'delete-item', + }), + ], + }), + ], + }, + }); + } + override connectedCallback() { super.connectedCallback(); this.disposables.addFromEvent(this, 'contextmenu', this.contextMenu); diff --git a/packages/affine/microsheet-data-view/src/widget-presets/tools/presets/search/search.ts b/packages/affine/microsheet-data-view/src/widget-presets/tools/presets/search/search.ts index eceb112b62c0..4796090bd659 100644 --- a/packages/affine/microsheet-data-view/src/widget-presets/tools/presets/search/search.ts +++ b/packages/affine/microsheet-data-view/src/widget-presets/tools/presets/search/search.ts @@ -5,7 +5,6 @@ import { query, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { styleMap } from 'lit/directives/style-map.js'; -import type { KanbanSingleView } from '../../../../view-presets/kanban/kanban-view-manager.js'; import type { TableSingleView } from '../../../../view-presets/table/table-view-manager.js'; import { stopPropagation } from '../../../../core/utils/event.js'; @@ -189,7 +188,7 @@ export class DataViewHeaderToolsSearch extends WidgetBase { @state() private accessor _showSearch = false; - public override accessor view!: TableSingleView | KanbanSingleView; + public override accessor view!: TableSingleView; } declare global { diff --git a/packages/affine/microsheet-data-view/src/widget-presets/tools/presets/view-options/view-options.ts b/packages/affine/microsheet-data-view/src/widget-presets/tools/presets/view-options/view-options.ts index bbf0e6110f2e..cdc0e7176845 100644 --- a/packages/affine/microsheet-data-view/src/widget-presets/tools/presets/view-options/view-options.ts +++ b/packages/affine/microsheet-data-view/src/widget-presets/tools/presets/view-options/view-options.ts @@ -30,8 +30,6 @@ import { popCreateFilter } from '../../../../core/common/ref/ref.js'; import { emptyFilterGroup, renderUniLit } from '../../../../core/index.js'; import { WidgetBase } from '../../../../core/widget/widget-base.js'; import { - KanbanSingleView, - type KanbanViewData, TableSingleView, type TableViewData, } from '../../../../view-presets/index.js'; @@ -95,7 +93,7 @@ export class DataViewHeaderToolsViewOptions extends WidgetBase { } } - override accessor view!: SingleView; + override accessor view!: SingleView; } declare global { @@ -105,7 +103,7 @@ declare global { } export const popViewOptions = ( target: PopupTarget, - view: SingleView, + view: SingleView, onClose?: () => void ) => { const reopen = () => { @@ -163,12 +161,7 @@ export const popViewOptions = (
${meta.model.defaultName}
`, - select: () => { - view.manager.viewChangeType( - view.manager.currentViewId$.value, - meta.type - ); - }, + select: () => {}, class: '', }; const containerStyle = styleMap({ @@ -269,8 +262,7 @@ export const popViewOptions = ( name: 'Group', prefix: GroupingIcon(), postfix: html`
- ${view instanceof TableSingleView || - view instanceof KanbanSingleView + ${view instanceof TableSingleView ? view.groupManager.property$.value?.name$.value : ''}
diff --git a/packages/affine/model/src/blocks/database/types.ts b/packages/affine/model/src/blocks/database/types.ts index b0e5aa4c40f9..c168606bf463 100644 --- a/packages/affine/model/src/blocks/database/types.ts +++ b/packages/affine/model/src/blocks/database/types.ts @@ -11,6 +11,7 @@ export type ColumnUpdater = (data: T) => Partial; export type Cell = { columnId: Column['id']; value: ValueType; + ref: string; }; export type SerializedCells = Record>; diff --git a/packages/affine/model/src/blocks/microsheet/index.ts b/packages/affine/model/src/blocks/microsheet/index.ts index d650b1350482..9351b5c89b94 100644 --- a/packages/affine/model/src/blocks/microsheet/index.ts +++ b/packages/affine/model/src/blocks/microsheet/index.ts @@ -1,2 +1 @@ export * from './microsheet-model.js'; -export * from './types.js'; diff --git a/packages/affine/model/src/blocks/microsheet/types.ts b/packages/affine/model/src/blocks/microsheet/types.ts index 8cbb6480a702..c168606bf463 100644 --- a/packages/affine/model/src/blocks/microsheet/types.ts +++ b/packages/affine/model/src/blocks/microsheet/types.ts @@ -1,21 +1,22 @@ -// export interface Column< -// Data extends Record = Record, -// > { -// id: string; -// type: string; -// name: string; -// data: Data; -// } +export interface Column< + Data extends Record = Record, +> { + id: string; + type: string; + name: string; + data: Data; +} -// export type ColumnUpdater = (data: T) => Partial; -// export type Cell = { -// columnId: Column['id']; -// value: ValueType; -// }; +export type ColumnUpdater = (data: T) => Partial; +export type Cell = { + columnId: Column['id']; + value: ValueType; + ref: string; +}; -// export type SerializedCells = Record>; -// export type ViewBasicDataType = { -// id: string; -// name: string; -// mode: string; -// }; +export type SerializedCells = Record>; +export type ViewBasicDataType = { + id: string; + name: string; + mode: string; +}; diff --git a/packages/blocks/src/microsheet-block/data-source.ts b/packages/blocks/src/microsheet-block/data-source.ts index c930b0d328de..cca6e5a1f213 100644 --- a/packages/blocks/src/microsheet-block/data-source.ts +++ b/packages/blocks/src/microsheet-block/data-source.ts @@ -44,11 +44,7 @@ import { updateProperty, updateView, } from './utils.js'; -import { - microsheetBlockViewConverts, - microsheetBlockViewMap, - microsheetBlockViews, -} from './views/index.js'; +import { microsheetBlockViewMap, microsheetBlockViews } from './views/index.js'; export class MicrosheetBlockDataSource extends DataSourceBase { private _batch = 0; @@ -78,8 +74,6 @@ export class MicrosheetBlockDataSource extends DataSourceBase { return this._model.children.map(v => v.id); }); - viewConverts = microsheetBlockViewConverts; - viewDataList$: ReadonlySignal = computed(() => { return this._model.views$.value as DataViewDataType[]; }); @@ -214,6 +208,12 @@ export class MicrosheetBlockDataSource extends DataSourceBase { const index = findPropertyIndex(this._model, id); if (index < 0) return; + this.rows$.value.forEach(rowId => { + const cell = this._model.cells[rowId][id]; + this.doc.getBlock(cell.ref)?.model && + this.doc.deleteBlock(this.doc.getBlock(cell.ref)!.model); + }); + this.doc.transact(() => { this._model.columns = this._model.columns.filter((_, i) => i !== index); }); @@ -358,6 +358,11 @@ export class MicrosheetBlockDataSource extends DataSourceBase { const block = this.doc.getBlock(id); if (block) { this.doc.deleteBlock(block.model); + const cell = this._model.cells[id]; + Object.values(cell).forEach(v => { + this.doc.getBlock(v.ref)?.model && + this.doc.deleteBlock(this.doc.getBlock(v.ref)!.model); + }); } } deleteRows(this._model, ids); diff --git a/packages/blocks/src/microsheet-block/detail-panel/block-renderer.ts b/packages/blocks/src/microsheet-block/detail-panel/block-renderer.ts index 742cab336791..6986af79e8a5 100644 --- a/packages/blocks/src/microsheet-block/detail-panel/block-renderer.ts +++ b/packages/blocks/src/microsheet-block/detail-panel/block-renderer.ts @@ -1,9 +1,6 @@ import type { EditorHost } from '@blocksuite/block-std'; import type { DetailSlotProps } from '@blocksuite/microsheet-data-view'; -import type { - KanbanSingleView, - TableSingleView, -} from '@blocksuite/microsheet-data-view/view-presets'; +import type { TableSingleView } from '@blocksuite/microsheet-data-view/view-presets'; import { DefaultInlineManagerExtension } from '@blocksuite/affine-components/rich-text'; import { ShadowlessElement } from '@blocksuite/block-std'; @@ -155,5 +152,5 @@ export class BlockRenderer accessor rowId!: string; @property({ attribute: false }) - accessor view!: TableSingleView | KanbanSingleView; + accessor view!: TableSingleView; } diff --git a/packages/blocks/src/microsheet-block/microsheet-block.ts b/packages/blocks/src/microsheet-block/microsheet-block.ts index c9b4fcac572e..58ca105843a3 100644 --- a/packages/blocks/src/microsheet-block/microsheet-block.ts +++ b/packages/blocks/src/microsheet-block/microsheet-block.ts @@ -280,11 +280,6 @@ export class MicrosheetBlockComponent extends CaptionedBlockComponent< widgetPresets.tools.viewOptions, widgetPresets.tools.tableAddRow, ], - kanban: [ - widgetPresets.tools.filter, - widgetPresets.tools.search, - widgetPresets.tools.viewOptions, - ], }); viewSelection$ = computed(() => { diff --git a/packages/blocks/src/microsheet-block/utils.ts b/packages/blocks/src/microsheet-block/utils.ts index 921f01f23f71..174b568f8f7b 100644 --- a/packages/blocks/src/microsheet-block/utils.ts +++ b/packages/blocks/src/microsheet-block/utils.ts @@ -5,7 +5,7 @@ import type { MicrosheetBlockModel, ViewBasicDataType, } from '@blocksuite/affine-model'; -import type { BlockStdScope } from '@blocksuite/block-std'; +import type { BlockStdScope, TextSelection } from '@blocksuite/block-std'; import type { BlockModel } from '@blocksuite/store'; import { @@ -150,6 +150,7 @@ export function getCell( return { columnId: 'title', value: rowId, + ref: '', }; } const yRow = model.cells$.value[rowId]; diff --git a/packages/blocks/src/microsheet-block/views/index.ts b/packages/blocks/src/microsheet-block/views/index.ts index 62e35bcf9710..4e0f06b78153 100644 --- a/packages/blocks/src/microsheet-block/views/index.ts +++ b/packages/blocks/src/microsheet-block/views/index.ts @@ -1,16 +1,9 @@ import type { ViewMeta } from '@blocksuite/microsheet-data-view'; -import { - viewConverts, - viewPresets, -} from '@blocksuite/microsheet-data-view/view-presets'; +import { viewPresets } from '@blocksuite/microsheet-data-view/view-presets'; -export const microsheetBlockViews: ViewMeta[] = [ - viewPresets.tableViewMeta, - viewPresets.kanbanViewMeta, -]; +export const microsheetBlockViews: ViewMeta[] = [viewPresets.tableViewMeta]; export const microsheetBlockViewMap = Object.fromEntries( microsheetBlockViews.map(view => [view.type, view]) ); -export const microsheetBlockViewConverts = [...viewConverts]; diff --git a/packages/blocks/src/microsheet-data-view-block/data-view-block.ts b/packages/blocks/src/microsheet-data-view-block/data-view-block.ts index 9041977d1af3..0dee0395f6b7 100644 --- a/packages/blocks/src/microsheet-data-view-block/data-view-block.ts +++ b/packages/blocks/src/microsheet-data-view-block/data-view-block.ts @@ -208,11 +208,6 @@ export class DataViewBlockComponent extends CaptionedBlockComponent [view.type, view]) From afdf48736943fd5a9c1fd39008766beb8caa82c7 Mon Sep 17 00:00:00 2001 From: "caojiafu@cvte.com" Date: Thu, 14 Nov 2024 17:20:54 +0800 Subject: [PATCH 06/13] fix(blocks): microsheet-block remove unsed file and impove the use-experience --- .vscode/launch.template.json | 2 +- .../src/core/common/ast.ts | 86 --- .../src/core/common/data-source/base.ts | 13 +- .../src/core/common/detail/field.ts | 2 +- .../src/core/common/group-by/define.ts | 138 +---- .../common/group-by/renderer/select-group.ts | 119 ----- .../src/core/common/group-by/setting.ts | 4 +- .../src/core/common/literal/define.ts | 163 +----- .../common/literal/renderer/array-literal.ts | 11 - .../common/literal/renderer/date-literal.ts | 12 - .../common/literal/renderer/tag-literal.ts | 74 --- .../common/literal/renderer/union-string.ts | 11 - .../src/core/common/properties.ts | 2 +- .../src/core/common/ref/ref.ts | 55 +- .../src/core/common/selection.ts | 19 +- .../src/core/common/stats/any.ts | 98 ---- .../src/core/common/stats/checkbox.ts | 62 --- .../src/core/common/stats/index.ts | 11 - .../src/core/common/stats/number.ts | 116 ---- .../src/core/common/stats/type.ts | 17 - .../src/core/data-view.ts | 38 +- .../microsheet-data-view/src/core/index.ts | 2 +- .../src/core/logical/data-type.ts | 34 -- .../src/core/logical/eval-filter.ts | 64 --- .../src/core/logical/property-matcher.ts | 102 ---- .../microsheet-data-view/src/core/types.ts | 12 +- .../src/core/utils/index.ts | 1 - .../src/core/utils/tags/colors.ts | 64 --- .../src/core/utils/tags/index.ts | 3 - .../src/core/utils/tags/multi-tag-select.ts | 503 ------------------ .../src/core/utils/tags/multi-tag-view.ts | 82 --- .../src/core/utils/tags/styles.ts | 200 ------- .../src/core/view-manager/property.ts | 4 - .../src/core/view-manager/single-view.ts | 45 +- .../src/core/view/data-view-base.ts | 4 +- .../src/core/view/types.ts | 10 +- .../src/core/widget/types.ts | 5 +- .../src/core/widget/widget-base.ts | 4 +- .../microsheet-data-view/src/effects.ts | 155 +----- .../checkbox/cell-renderer.ts | 121 ----- .../src/property-presets/checkbox/define.ts | 19 - .../src/property-presets/converts.ts | 45 -- .../property-presets/date/cell-renderer.ts | 151 ------ .../src/property-presets/date/define.ts | 19 - .../property-presets/image/cell-renderer.ts | 32 -- .../src/property-presets/image/define.ts | 18 - .../src/property-presets/index.ts | 17 - .../multi-select/cell-renderer.ts | 97 ---- .../property-presets/multi-select/define.ts | 70 --- .../property-presets/number/cell-renderer.ts | 194 ------- .../src/property-presets/number/define.ts | 24 - .../src/property-presets/number/index.ts | 1 - .../src/property-presets/number/types.ts | 6 - .../property-presets/number/utils/formats.ts | 19 - .../number/utils/formatter.ts | 101 ---- .../progress/cell-renderer.ts | 223 -------- .../src/property-presets/progress/define.ts | 20 - .../src/property-presets/pure-index.ts | 14 - .../property-presets/select/cell-renderer.ts | 98 ---- .../src/property-presets/select/define.ts | 66 --- .../src/view-presets/table/cell.ts | 65 +-- .../src/view-presets/table/components/menu.ts | 130 ----- .../table/controller/clipboard.ts | 9 +- .../src/view-presets/table/controller/drag.ts | 7 +- .../view-presets/table/controller/hotkeys.ts | 411 ++------------ .../table/controller/selection.ts | 77 +-- .../src/view-presets/table/define.ts | 2 - .../src/view-presets/table/group.ts | 63 +-- .../table/header/column-header.ts | 10 - .../table/header/microsheet-header-column.ts | 27 +- .../table/header/number-format-bar.ts | 145 ----- .../src/view-presets/table/row/row.ts | 23 +- .../table/stats/column-stats-bar.ts | 56 -- .../table/stats/column-stats-column.ts | 229 -------- .../view-presets/table/table-view-manager.ts | 25 +- .../src/view-presets/table/table-view.ts | 1 - .../src/widget-presets/filter/condition.ts | 250 --------- .../src/widget-presets/filter/context.ts | 7 - .../src/widget-presets/filter/filter-bar.ts | 253 --------- .../src/widget-presets/filter/filter-group.ts | 384 ------------- .../src/widget-presets/filter/filter-modal.ts | 115 ---- .../src/widget-presets/filter/filter-root.ts | 318 ----------- .../src/widget-presets/filter/index.ts | 23 - .../widget-presets/filter/matcher/boolean.ts | 21 - .../src/widget-presets/filter/matcher/date.ts | 33 -- .../widget-presets/filter/matcher/matcher.ts | 56 -- .../filter/matcher/multi-tag.ts | 69 --- .../widget-presets/filter/matcher/number.ts | 91 ---- .../widget-presets/filter/matcher/string.ts | 109 ---- .../src/widget-presets/filter/matcher/tag.ts | 40 -- .../widget-presets/filter/matcher/unknown.ts | 33 -- .../src/widget-presets/index.ts | 5 +- .../src/widget-presets/tools/index.ts | 21 +- .../tools/presets/filter/filter.ts | 116 ---- .../tools/presets/search/search.ts | 198 ------- .../tools/presets/table-add-row/add-row.ts | 214 -------- .../table-add-row/new-record-preview.ts | 43 -- .../presets/view-options/view-options.ts | 307 ----------- .../widget-presets/tools/tools-renderer.ts | 12 +- .../src/widget-presets/views-bar/index.ts | 5 - .../src/widget-presets/views-bar/views.ts | 297 ----------- .../affine/model/src/blocks/database/types.ts | 1 - .../model/src/blocks/microsheet/index.ts | 1 + .../src/blocks/microsheet/microsheet-model.ts | 6 +- .../model/src/blocks/microsheet/types.ts | 17 +- .../model/src/blocks/note/note-model.ts | 1 + packages/affine/shared/src/types/index.ts | 2 + packages/blocks/src/_specs/common.ts | 3 + packages/blocks/src/_specs/group/common.ts | 2 + packages/blocks/src/cell-block/cell-block.ts | 4 + .../blocks/src/cell-block/cell-service.ts | 6 - .../src/cell-block/keymap-controller.ts | 322 ++--------- packages/blocks/src/effects.ts | 5 + .../src/microsheet-block/data-source.ts | 50 +- .../detail-panel/block-renderer.ts | 156 ------ .../detail-panel/note-renderer.ts | 132 ----- .../src/microsheet-block/microsheet-block.ts | 100 +--- .../microsheet-block/microsheet-service.ts | 9 +- .../microsheet-block/properties/converts.ts | 177 ------ .../src/microsheet-block/properties/index.ts | 24 +- packages/blocks/src/microsheet-block/utils.ts | 13 +- .../block-meta/base.ts | 2 +- .../block-meta/index.ts | 6 +- .../block-meta/todo.ts | 60 --- .../columns/index.ts | 13 +- .../microsheet-data-view-block/data-source.ts | 20 +- .../data-view-block.ts | 97 +--- .../data-view-model.ts | 8 +- .../data-view-spec.ts | 13 +- .../database-service.ts | 13 - .../src/microsheet-data-view-block/index.ts | 4 +- .../microsheet-service.ts | 14 + .../microsheet-data-view-block/views/index.ts | 4 +- packages/blocks/src/note-block/note-block.ts | 2 +- .../src/root-block/clipboard/adapter.ts | 10 +- .../root-block/widgets/slash-menu/config.ts | 4 +- packages/blocks/src/row-block/row-block.ts | 5 +- .../block-std/src/range/range-binding.ts | 2 + .../block-std/src/view/decorators/required.ts | 7 - .../framework/global/src/exceptions/code.ts | 1 + .../framework/inline/src/__tests__/utils.ts | 2 +- packages/playground/vite.config.ts | 3 + tests/playwright.config.ts | 2 +- tests/utils/actions/misc.ts | 2 +- 144 files changed, 368 insertions(+), 8959 deletions(-) delete mode 100644 packages/affine/microsheet-data-view/src/core/common/group-by/renderer/select-group.ts delete mode 100644 packages/affine/microsheet-data-view/src/core/common/literal/renderer/array-literal.ts delete mode 100644 packages/affine/microsheet-data-view/src/core/common/literal/renderer/date-literal.ts delete mode 100644 packages/affine/microsheet-data-view/src/core/common/literal/renderer/tag-literal.ts delete mode 100644 packages/affine/microsheet-data-view/src/core/common/literal/renderer/union-string.ts delete mode 100644 packages/affine/microsheet-data-view/src/core/common/stats/any.ts delete mode 100644 packages/affine/microsheet-data-view/src/core/common/stats/checkbox.ts delete mode 100644 packages/affine/microsheet-data-view/src/core/common/stats/index.ts delete mode 100644 packages/affine/microsheet-data-view/src/core/common/stats/number.ts delete mode 100644 packages/affine/microsheet-data-view/src/core/common/stats/type.ts delete mode 100644 packages/affine/microsheet-data-view/src/core/logical/eval-filter.ts delete mode 100644 packages/affine/microsheet-data-view/src/core/logical/property-matcher.ts delete mode 100644 packages/affine/microsheet-data-view/src/core/utils/tags/colors.ts delete mode 100644 packages/affine/microsheet-data-view/src/core/utils/tags/index.ts delete mode 100644 packages/affine/microsheet-data-view/src/core/utils/tags/multi-tag-select.ts delete mode 100644 packages/affine/microsheet-data-view/src/core/utils/tags/multi-tag-view.ts delete mode 100644 packages/affine/microsheet-data-view/src/core/utils/tags/styles.ts delete mode 100644 packages/affine/microsheet-data-view/src/property-presets/checkbox/cell-renderer.ts delete mode 100644 packages/affine/microsheet-data-view/src/property-presets/checkbox/define.ts delete mode 100644 packages/affine/microsheet-data-view/src/property-presets/converts.ts delete mode 100644 packages/affine/microsheet-data-view/src/property-presets/date/cell-renderer.ts delete mode 100644 packages/affine/microsheet-data-view/src/property-presets/date/define.ts delete mode 100644 packages/affine/microsheet-data-view/src/property-presets/image/cell-renderer.ts delete mode 100644 packages/affine/microsheet-data-view/src/property-presets/image/define.ts delete mode 100644 packages/affine/microsheet-data-view/src/property-presets/multi-select/cell-renderer.ts delete mode 100644 packages/affine/microsheet-data-view/src/property-presets/multi-select/define.ts delete mode 100644 packages/affine/microsheet-data-view/src/property-presets/number/cell-renderer.ts delete mode 100644 packages/affine/microsheet-data-view/src/property-presets/number/define.ts delete mode 100644 packages/affine/microsheet-data-view/src/property-presets/number/index.ts delete mode 100644 packages/affine/microsheet-data-view/src/property-presets/number/types.ts delete mode 100644 packages/affine/microsheet-data-view/src/property-presets/number/utils/formats.ts delete mode 100644 packages/affine/microsheet-data-view/src/property-presets/number/utils/formatter.ts delete mode 100644 packages/affine/microsheet-data-view/src/property-presets/progress/cell-renderer.ts delete mode 100644 packages/affine/microsheet-data-view/src/property-presets/progress/define.ts delete mode 100644 packages/affine/microsheet-data-view/src/property-presets/select/cell-renderer.ts delete mode 100644 packages/affine/microsheet-data-view/src/property-presets/select/define.ts delete mode 100644 packages/affine/microsheet-data-view/src/view-presets/table/components/menu.ts delete mode 100644 packages/affine/microsheet-data-view/src/view-presets/table/header/number-format-bar.ts delete mode 100644 packages/affine/microsheet-data-view/src/view-presets/table/stats/column-stats-bar.ts delete mode 100644 packages/affine/microsheet-data-view/src/view-presets/table/stats/column-stats-column.ts delete mode 100644 packages/affine/microsheet-data-view/src/widget-presets/filter/condition.ts delete mode 100644 packages/affine/microsheet-data-view/src/widget-presets/filter/context.ts delete mode 100644 packages/affine/microsheet-data-view/src/widget-presets/filter/filter-bar.ts delete mode 100644 packages/affine/microsheet-data-view/src/widget-presets/filter/filter-group.ts delete mode 100644 packages/affine/microsheet-data-view/src/widget-presets/filter/filter-modal.ts delete mode 100644 packages/affine/microsheet-data-view/src/widget-presets/filter/filter-root.ts delete mode 100644 packages/affine/microsheet-data-view/src/widget-presets/filter/index.ts delete mode 100644 packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/boolean.ts delete mode 100644 packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/date.ts delete mode 100644 packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/matcher.ts delete mode 100644 packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/multi-tag.ts delete mode 100644 packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/number.ts delete mode 100644 packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/string.ts delete mode 100644 packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/tag.ts delete mode 100644 packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/unknown.ts delete mode 100644 packages/affine/microsheet-data-view/src/widget-presets/tools/presets/filter/filter.ts delete mode 100644 packages/affine/microsheet-data-view/src/widget-presets/tools/presets/search/search.ts delete mode 100644 packages/affine/microsheet-data-view/src/widget-presets/tools/presets/table-add-row/add-row.ts delete mode 100644 packages/affine/microsheet-data-view/src/widget-presets/tools/presets/table-add-row/new-record-preview.ts delete mode 100644 packages/affine/microsheet-data-view/src/widget-presets/tools/presets/view-options/view-options.ts delete mode 100644 packages/affine/microsheet-data-view/src/widget-presets/views-bar/index.ts delete mode 100644 packages/affine/microsheet-data-view/src/widget-presets/views-bar/views.ts delete mode 100644 packages/blocks/src/microsheet-block/detail-panel/block-renderer.ts delete mode 100644 packages/blocks/src/microsheet-block/detail-panel/note-renderer.ts delete mode 100644 packages/blocks/src/microsheet-block/properties/converts.ts delete mode 100644 packages/blocks/src/microsheet-data-view-block/block-meta/todo.ts delete mode 100644 packages/blocks/src/microsheet-data-view-block/database-service.ts create mode 100644 packages/blocks/src/microsheet-data-view-block/microsheet-service.ts diff --git a/.vscode/launch.template.json b/.vscode/launch.template.json index ec18a9d4cc18..39508d69b862 100644 --- a/.vscode/launch.template.json +++ b/.vscode/launch.template.json @@ -19,7 +19,7 @@ "request": "launch", "name": "Debug Playground", "skipFiles": ["/**", "**/node_modules/**"], - "url": "http://localhost:5173/starter/?init" + "url": "http://localhost:8001/starter/?init" } ] } diff --git a/packages/affine/microsheet-data-view/src/core/common/ast.ts b/packages/affine/microsheet-data-view/src/core/common/ast.ts index ce01547b0339..710ae963516d 100644 --- a/packages/affine/microsheet-data-view/src/core/common/ast.ts +++ b/packages/affine/microsheet-data-view/src/core/common/ast.ts @@ -1,22 +1,12 @@ -import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; - import type { TType } from '../logical/typesystem.js'; import type { UniComponent } from '../utils/uni-component/uni-component.js'; -import { filterMatcher } from '../../widget-presets/filter/matcher/matcher.js'; -import { propertyMatcher } from '../logical/property-matcher.js'; - export type Variable = { name: string; type: TType; id: string; icon?: UniComponent; }; -export type FilterGroup = { - type: 'group'; - op: 'and' | 'or'; - conditions: Filter[]; -}; export type VariableRef = { type: 'ref'; name: string; @@ -35,85 +25,9 @@ export type Literal = { value: unknown; }; export type Value = /*VariableRef*/ Literal; -export type SingleFilter = { - type: 'filter'; - left: VariableOrProperty; - function?: string; - args: Value[]; -}; -export type Filter = SingleFilter | FilterGroup; -export type SortExp = { - left: VariableOrProperty; - type: 'asc' | 'desc'; -}; - -export type SortGroup = SortExp[]; - export type GroupExp = { left: VariableOrProperty; type: 'asc' | 'desc'; }; export type GroupList = GroupExp[]; -export const getRefType = (vars: Variable[], ref: VariableOrProperty) => { - if (ref.type === 'ref') { - return vars.find(v => v.id === ref.name)?.type; - } - return propertyMatcher.find(v => v.data.name === ref.propertyFuncName)?.type - .rt; -}; -export const firstFilterName = (vars: Variable[], ref: VariableOrProperty) => { - const type = getRefType(vars, ref); - if (!type) { - throw new BlockSuiteError( - ErrorCode.MicrosheetBlockError, - `can't resolve ref type` - ); - } - return filterMatcher.match(type)?.name; -}; - -export const firstFilterByRef = ( - vars: Variable[], - ref: VariableOrProperty -): SingleFilter => { - return { - type: 'filter', - left: ref, - function: firstFilterName(vars, ref), - args: [], - }; -}; - -export const firstFilter = (vars: Variable[]): SingleFilter => { - const ref: VariableRef = { - type: 'ref', - name: vars[0].id, - }; - const filter = firstFilterName(vars, ref); - if (!filter) { - throw new BlockSuiteError( - ErrorCode.MicrosheetBlockError, - `can't match any filter` - ); - } - return { - type: 'filter', - left: ref, - function: filter, - args: [], - }; -}; - -export const firstFilterInGroup = (vars: Variable[]): FilterGroup => { - return { - type: 'group', - op: 'and', - conditions: [firstFilter(vars)], - }; -}; -export const emptyFilterGroup: FilterGroup = { - type: 'group', - op: 'and', - conditions: [], -}; diff --git a/packages/affine/microsheet-data-view/src/core/common/data-source/base.ts b/packages/affine/microsheet-data-view/src/core/common/data-source/base.ts index 56cace5831f7..e46e450f5ced 100644 --- a/packages/affine/microsheet-data-view/src/core/common/data-source/base.ts +++ b/packages/affine/microsheet-data-view/src/core/common/data-source/base.ts @@ -4,8 +4,6 @@ import { computed, type ReadonlySignal } from '@preact/signals-core'; import type { TType } from '../../logical/index.js'; import type { PropertyMetaConfig } from '../../property/property-config.js'; -import type { MicrosheetFlags } from '../../types.js'; -import type { ViewConvertConfig } from '../../view/convert.js'; import type { DataViewDataType, ViewMeta } from '../../view/data-view.js'; import type { ViewManager } from '../../view-manager/view-manager.js'; import type { DataViewContextKey } from './context.js'; @@ -13,7 +11,6 @@ import type { DataViewContextKey } from './context.js'; export interface DataSource { readonly$: ReadonlySignal; properties$: ReadonlySignal; - featureFlags$: ReadonlySignal; cellValueGet(rowId: string, propertyId: string): unknown; cellRefGet(rowId: string, propertyId: string): unknown; @@ -36,7 +33,6 @@ export interface DataSource { propertyTypeGet(propertyId: string): string | undefined; propertyTypeGet$(propertyId: string): ReadonlySignal; - propertyTypeSet(propertyId: string, type: string): void; propertyDataGet(propertyId: string): Record; propertyDataGet$( @@ -57,7 +53,6 @@ export interface DataSource { contextGet(key: DataViewContextKey): T; - viewConverts: ViewConvertConfig[]; viewManager: ViewManager; viewMetas: ViewMeta[]; viewDataList$: ReadonlySignal; @@ -84,8 +79,6 @@ export interface DataSource { export abstract class DataSourceBase implements DataSource { context = new Map(); - abstract featureFlags$: ReadonlySignal; - abstract properties$: ReadonlySignal; abstract propertyMetas: PropertyMetaConfig[]; @@ -94,14 +87,14 @@ export abstract class DataSourceBase implements DataSource { abstract rows$: ReadonlySignal; - abstract viewConverts: ViewConvertConfig[]; - abstract viewDataList$: ReadonlySignal; abstract viewManager: ViewManager; abstract viewMetas: ViewMeta[]; + abstract cellRefGet(rowId: string, propertyId: string): unknown; + abstract cellValueChange( rowId: string, propertyId: string, @@ -183,8 +176,6 @@ export abstract class DataSourceBase implements DataSource { return computed(() => this.propertyTypeGet(propertyId)); } - abstract propertyTypeSet(propertyId: string, type: string): void; - abstract rowAdd(InsertToPosition: InsertToPosition | number): string; abstract rowDelete(ids: string[]): void; diff --git a/packages/affine/microsheet-data-view/src/core/common/detail/field.ts b/packages/affine/microsheet-data-view/src/core/common/detail/field.ts index 7b78832296eb..973534a80bb5 100644 --- a/packages/affine/microsheet-data-view/src/core/common/detail/field.ts +++ b/packages/affine/microsheet-data-view/src/core/common/detail/field.ts @@ -197,7 +197,7 @@ export class RecordField extends SignalWatcher( select: () => { this.column.delete?.(); }, - class: 'delete-item', + class: { 'delete-item': true }, }), ], }), diff --git a/packages/affine/microsheet-data-view/src/core/common/group-by/define.ts b/packages/affine/microsheet-data-view/src/core/common/group-by/define.ts index 5ec6c7b0d0d4..d9889bf47b4b 100644 --- a/packages/affine/microsheet-data-view/src/core/common/group-by/define.ts +++ b/packages/affine/microsheet-data-view/src/core/common/group-by/define.ts @@ -1,12 +1,8 @@ import type { GroupByConfig } from './types.js'; -import { tBoolean, tNumber, tString, tTag } from '../../logical/data-type.js'; +import { tString } from '../../logical/data-type.js'; import { MatcherCreator } from '../../logical/matcher.js'; -import { isTArray, tArray } from '../../logical/typesystem.js'; import { createUniComponentFromWebComponent } from '../../utils/uni-component/uni-component.js'; -import { BooleanGroupView } from './renderer/boolean-group.js'; -import { NumberGroupView } from './renderer/number-group.js'; -import { SelectGroupView } from './renderer/select-group.js'; import { StringGroupView } from './renderer/string-group.js'; const groupByMatcherCreator = new MatcherCreator(); @@ -15,87 +11,6 @@ const ungroups = { value: null, }; export const groupByMatchers = [ - groupByMatcherCreator.createMatcher(tTag.create(), { - name: 'select', - groupName: (type, value) => { - if (tTag.is(type) && type.data) { - return type.data.tags.find(v => v.id === value)?.value ?? ''; - } - return ''; - }, - defaultKeys: type => { - if (tTag.is(type) && type.data) { - return [ - ungroups, - ...type.data.tags.map(v => ({ - key: v.id, - value: v.id, - })), - ]; - } - return [ungroups]; - }, - valuesGroup: (value, _type) => { - if (value == null) { - return [ungroups]; - } - return [ - { - key: `${value}`, - value, - }, - ]; - }, - view: createUniComponentFromWebComponent(SelectGroupView), - }), - groupByMatcherCreator.createMatcher(tArray(tTag.create()), { - name: 'multi-select', - groupName: (type, value) => { - if (tTag.is(type) && type.data) { - return type.data.tags.find(v => v.id === value)?.value ?? ''; - } - return ''; - }, - defaultKeys: type => { - if (isTArray(type) && tTag.is(type.ele) && type.ele.data) { - return [ - ungroups, - ...type.ele.data.tags.map(v => ({ - key: v.id, - value: v.id, - })), - ]; - } - return [ungroups]; - }, - valuesGroup: (value, _type) => { - if (value == null) { - return [ungroups]; - } - if (Array.isArray(value)) { - if (value.length) { - return value.map(id => ({ - key: `${id}`, - value: id, - })); - } - } - return [ungroups]; - }, - addToGroup: (value, old) => { - if (value == null) { - return old; - } - return Array.isArray(old) ? [...old, value] : [value]; - }, - removeFromGroup: (value, old) => { - if (Array.isArray(old)) { - return old.filter(v => v !== value); - } - return old; - }, - view: createUniComponentFromWebComponent(SelectGroupView), - }), groupByMatcherCreator.createMatcher(tString.create(), { name: 'text', groupName: (_type, value) => { @@ -117,55 +32,4 @@ export const groupByMatchers = [ }, view: createUniComponentFromWebComponent(StringGroupView), }), - groupByMatcherCreator.createMatcher(tNumber.create(), { - name: 'number', - groupName: (_type, value) => { - return `${value ?? ''}`; - }, - defaultKeys: _type => { - return [ungroups]; - }, - valuesGroup: (value, _type) => { - if (typeof value !== 'number') { - return [ungroups]; - } - return [ - { - key: `g:${Math.floor(value / 10)}`, - value: Math.floor(value / 10), - }, - ]; - }, - addToGroup: value => (typeof value === 'number' ? value * 10 : undefined), - view: createUniComponentFromWebComponent(NumberGroupView), - }), - groupByMatcherCreator.createMatcher(tBoolean.create(), { - name: 'boolean', - groupName: (_type, value) => { - return `${value?.toString() ?? ''}`; - }, - defaultKeys: _type => { - return [ - { key: 'true', value: true }, - { key: 'false', value: false }, - ]; - }, - valuesGroup: (value, _type) => { - if (typeof value !== 'boolean') { - return [ - { - key: 'false', - value: false, - }, - ]; - } - return [ - { - key: value.toString(), - value: value, - }, - ]; - }, - view: createUniComponentFromWebComponent(BooleanGroupView), - }), ]; diff --git a/packages/affine/microsheet-data-view/src/core/common/group-by/renderer/select-group.ts b/packages/affine/microsheet-data-view/src/core/common/group-by/renderer/select-group.ts deleted file mode 100644 index 4b628fc80fd4..000000000000 --- a/packages/affine/microsheet-data-view/src/core/common/group-by/renderer/select-group.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { - menu, - popMenu, - popupTargetFromElement, -} from '@blocksuite/affine-components/context-menu'; -import { css, html } from 'lit'; -import { classMap } from 'lit/directives/class-map.js'; -import { styleMap } from 'lit/directives/style-map.js'; - -import type { SelectTag } from '../../../utils/tags/multi-tag-select.js'; - -import { selectOptionColors } from '../../../utils/tags/colors.js'; -import { BaseGroup } from './base.js'; - -export class SelectGroupView extends BaseGroup< - { - options: SelectTag[]; - }, - string -> { - static override styles = css` - data-view-group-title-select-view { - overflow: hidden; - } - - .data-view-group-title-select-view { - width: 100%; - cursor: pointer; - } - - .data-view-group-title-select-view.readonly { - cursor: inherit; - } - - .tag { - padding: 0 8px; - border-radius: 4px; - font-size: var(--data-view-cell-text-size); - line-height: var(--data-view-cell-text-line-height); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - `; - - private _click = () => { - if (this.readonly) { - return; - } - popMenu(popupTargetFromElement(this), { - options: { - items: [ - menu.input({ - initialValue: this.tag?.value ?? '', - onComplete: text => { - this.updateTag({ value: text }); - }, - }), - ...selectOptionColors.map(({ color, name }) => { - const styles = styleMap({ - backgroundColor: color, - borderRadius: '50%', - width: '20px', - height: '20px', - }); - return menu.action({ - name: name, - isSelected: this.tag?.color === color, - prefix: html`
`, - select: () => { - this.updateTag({ color }); - }, - }); - }), - ], - }, - }); - }; - - get tag() { - return this.data.options.find(v => v.id === this.value); - } - - protected override render(): unknown { - const tag = this.tag; - if (!tag) { - return html`
- Ungroups -
`; - } - const style = styleMap({ - backgroundColor: tag.color, - }); - const classList = classMap({ - 'data-view-group-title-select-view': true, - readonly: this.readonly, - }); - return html`
-
${tag.value}
-
`; - } - - updateTag(tag: Partial) { - this.updateData?.({ - ...this.data, - options: this.data.options.map(v => { - if (v.id === this.value) { - return { - ...v, - ...tag, - }; - } - return v; - }), - }); - } -} diff --git a/packages/affine/microsheet-data-view/src/core/common/group-by/setting.ts b/packages/affine/microsheet-data-view/src/core/common/group-by/setting.ts index 544dd7f8c9db..4bddfc97b103 100644 --- a/packages/affine/microsheet-data-view/src/core/common/group-by/setting.ts +++ b/packages/affine/microsheet-data-view/src/core/common/group-by/setting.ts @@ -191,7 +191,7 @@ export const selectGroupByProperty = ( menu.action({ prefix: DeleteIcon(), hide: () => view.data$.value?.groupBy == null, - class: 'delete-item', + class: { 'delete-item': true }, name: 'Remove Grouping', select: () => { if (view instanceof TableSingleView) { @@ -281,7 +281,7 @@ export const popGroupSetting = ( menu.action({ name: 'Remove grouping', prefix: DeleteIcon(), - class: 'delete-item', + class: { 'delete-item': true }, hide: () => !(view instanceof TableSingleView), select: () => { if (view instanceof TableSingleView) { diff --git a/packages/affine/microsheet-data-view/src/core/common/literal/define.ts b/packages/affine/microsheet-data-view/src/core/common/literal/define.ts index 72e05215d3ff..906b6f92124a 100644 --- a/packages/affine/microsheet-data-view/src/core/common/literal/define.ts +++ b/packages/affine/microsheet-data-view/src/core/common/literal/define.ts @@ -1,52 +1,14 @@ -import { - createPopup, - menu, - popMenu, -} from '@blocksuite/affine-components/context-menu'; -import { computed, signal } from '@preact/signals-core'; -import { html } from 'lit'; -import { styleMap } from 'lit/directives/style-map.js'; +import { menu, popMenu } from '@blocksuite/affine-components/context-menu'; import type { LiteralData } from './types.js'; -import { - tBoolean, - tDate, - tNumber, - tString, - tTag, -} from '../../logical/data-type.js'; +import { tString } from '../../logical/data-type.js'; import { MatcherCreator } from '../../logical/matcher.js'; -import { isTArray, tArray } from '../../logical/typesystem.js'; import { createUniComponentFromWebComponent } from '../../utils/uni-component/uni-component.js'; -import { DateLiteral } from './renderer/date-literal.js'; -import { - BooleanLiteral, - NumberLiteral, - StringLiteral, -} from './renderer/literal-element.js'; -import { MultiTagLiteral, TagLiteral } from './renderer/tag-literal.js'; +import { StringLiteral } from './renderer/literal-element.js'; const literalMatcherCreator = new MatcherCreator(); export const literalMatchers = [ - literalMatcherCreator.createMatcher(tBoolean.create(), { - view: createUniComponentFromWebComponent(BooleanLiteral), - popEdit: (position, { value$, onChange }) => { - popMenu(position, { - options: { - items: [true, false].map(v => { - return menu.action({ - name: v.toString().toUpperCase(), - isSelected: v === value$.value, - select: () => { - onChange(v); - }, - }); - }), - }, - }); - }, - }), literalMatcherCreator.createMatcher(tString.create(), { view: createUniComponentFromWebComponent(StringLiteral), popEdit: (position, { value$, onChange }) => { @@ -64,123 +26,4 @@ export const literalMatchers = [ }); }, }), - literalMatcherCreator.createMatcher(tNumber.create(), { - view: createUniComponentFromWebComponent(NumberLiteral), - popEdit: (position, { value$, onChange }) => { - popMenu(position, { - options: { - items: [ - menu.input({ - initialValue: value$.value?.toString() ?? '', - onComplete: text => { - if (!text) { - onChange(undefined); - return; - } - const number = Number.parseFloat(text); - if (!Number.isNaN(number)) { - onChange(number); - } - }, - }), - ], - }, - }); - }, - }), - literalMatcherCreator.createMatcher(tArray(tTag.create()), { - view: createUniComponentFromWebComponent(MultiTagLiteral), - popEdit: (position, { value$, onChange, type }) => { - if (!isTArray(type)) { - return; - } - if (!tTag.is(type.ele)) { - return; - } - const list$ = signal(Array.isArray(value$.value) ? value$.value : []); - // const list$ = computed(()=>{ - // return Array.isArray(value$.value) ? value$.value : [] - // }); - popMenu(position, { - options: { - items: - type.ele.data?.tags.map(tag => { - const styles = styleMap({ - backgroundColor: tag.color, - padding: '0 8px', - width: 'max-content', - }); - return menu.checkbox({ - name: tag.value, - checked: computed(() => list$.value.includes(tag.id)), - label: () => - html`
- ${tag.value} -
`, - select: checked => { - if (checked) { - list$.value = list$.value.filter(v => v !== tag.id); - onChange(list$.value); - return false; - } else { - list$.value = [...list$.value, tag.id]; - onChange(list$.value); - return true; - } - }, - }); - }) ?? [], - }, - }); - }, - }), - literalMatcherCreator.createMatcher(tTag.create(), { - view: createUniComponentFromWebComponent(TagLiteral), - popEdit: (position, { onChange, type }) => { - if (!tTag.is(type)) { - return; - } - popMenu(position, { - options: { - items: - type.data?.tags.map(tag => { - const styles = styleMap({ - backgroundColor: tag.color, - padding: '0 8px', - width: 'max-content', - }); - return menu.action({ - name: tag.value, - label: () => - html`
- ${tag.value} -
`, - select: () => { - onChange(tag.id); - }, - }); - }) ?? [], - }, - }); - }, - }), - literalMatcherCreator.createMatcher(tDate.create(), { - view: createUniComponentFromWebComponent(DateLiteral), - popEdit: (position, { value$, onChange }) => { - const input = document.createElement('input'); - input.type = 'date'; - input.click(); - input.valueAsNumber = value$.value as number; - document.body.append(input); - input.style.position = 'absolute'; - const close = createPopup(position, input); - requestAnimationFrame(() => { - input.showPicker(); - input.onchange = () => { - onChange(input.valueAsNumber); - close(); - }; - }); - }, - }), ]; diff --git a/packages/affine/microsheet-data-view/src/core/common/literal/renderer/array-literal.ts b/packages/affine/microsheet-data-view/src/core/common/literal/renderer/array-literal.ts deleted file mode 100644 index 0e8b2f718259..000000000000 --- a/packages/affine/microsheet-data-view/src/core/common/literal/renderer/array-literal.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { html } from 'lit'; - -import type { TArray } from '../../../logical/typesystem.js'; - -import { LiteralElement } from './literal-element.js'; - -export class ArrayLiteral extends LiteralElement { - override render() { - return html``; - } -} diff --git a/packages/affine/microsheet-data-view/src/core/common/literal/renderer/date-literal.ts b/packages/affine/microsheet-data-view/src/core/common/literal/renderer/date-literal.ts deleted file mode 100644 index 70ef2cbbf24c..000000000000 --- a/packages/affine/microsheet-data-view/src/core/common/literal/renderer/date-literal.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { format } from 'date-fns/format'; -import { html } from 'lit'; - -import { LiteralElement } from './literal-element.js'; - -export class DateLiteral extends LiteralElement { - override render() { - return this.value$.value - ? format(new Date(this.value$.value), 'yyyy/MM/dd') - : html`Value`; - } -} diff --git a/packages/affine/microsheet-data-view/src/core/common/literal/renderer/tag-literal.ts b/packages/affine/microsheet-data-view/src/core/common/literal/renderer/tag-literal.ts deleted file mode 100644 index 6eabc41ea9b3..000000000000 --- a/packages/affine/microsheet-data-view/src/core/common/literal/renderer/tag-literal.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { css, html } from 'lit'; - -import type { TArray, TypeOfData } from '../../../logical/typesystem.js'; - -import { tTag } from '../../../logical/data-type.js'; -import { LiteralElement } from './literal-element.js'; - -export class TagLiteral extends LiteralElement< - string, - TypeOfData -> { - static override styles = css` - data-view-literal-tag-view { - max-width: 100px; - display: block; - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - } - `; - - override render() { - if (!this.value$.value) { - return html`Value`; - } - return ( - this.tags().find(v => v.id === this.value$.value)?.value ?? - html`Value` - ); - } - - tags() { - const tags = this.type.data?.tags; - if (!tags) { - return []; - } - return tags; - } -} - -export class MultiTagLiteral extends LiteralElement< - string[], - TArray> -> { - static override styles = css` - data-view-literal-multi-tag-view { - max-width: 100px; - display: block; - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - } - `; - - override render() { - if (!this.value$.value?.length) { - return html`Value`; - } - const tagMap = new Map(this.tags().map(v => [v.id, v.value])); - return html`${this.value$.value.map(id => tagMap.get(id)).join(', ')}`; - } - - tags() { - const type = this.type.ele; - if (!tTag.is(type)) { - return []; - } - const tags = type.data?.tags; - if (!tags) { - return []; - } - return tags; - } -} diff --git a/packages/affine/microsheet-data-view/src/core/common/literal/renderer/union-string.ts b/packages/affine/microsheet-data-view/src/core/common/literal/renderer/union-string.ts deleted file mode 100644 index ab2cf3045006..000000000000 --- a/packages/affine/microsheet-data-view/src/core/common/literal/renderer/union-string.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { html } from 'lit'; - -import type { TUnion } from '../../../logical/typesystem.js'; - -import { LiteralElement } from './literal-element.js'; - -export class TagLiteral extends LiteralElement { - override render() { - return html``; - } -} diff --git a/packages/affine/microsheet-data-view/src/core/common/properties.ts b/packages/affine/microsheet-data-view/src/core/common/properties.ts index 50f248f43e4a..c5812a885614 100644 --- a/packages/affine/microsheet-data-view/src/core/common/properties.ts +++ b/packages/affine/microsheet-data-view/src/core/common/properties.ts @@ -207,7 +207,7 @@ export class DataViewPropertiesSettingView extends SignalWatcher( declare global { interface HTMLElementTagNameMap { - 'data-view-properties-setting': DataViewPropertiesSettingView; + 'microsheet-data-view-properties-setting': DataViewPropertiesSettingView; } } diff --git a/packages/affine/microsheet-data-view/src/core/common/ref/ref.ts b/packages/affine/microsheet-data-view/src/core/common/ref/ref.ts index 31cf0ebda1e5..31e2c0b809e8 100644 --- a/packages/affine/microsheet-data-view/src/core/common/ref/ref.ts +++ b/packages/affine/microsheet-data-view/src/core/common/ref/ref.ts @@ -1,22 +1,16 @@ -import type { ReadonlySignal } from '@preact/signals-core'; - import { menu, popFilterableSimpleMenu, - popMenu, - type PopupTarget, popupTargetFromElement, } from '@blocksuite/affine-components/context-menu'; import { ShadowlessElement } from '@blocksuite/block-std'; import { WithDisposable } from '@blocksuite/global/utils'; -import { AddCursorIcon } from '@blocksuite/icons/lit'; import { css, html } from 'lit'; import { property } from 'lit/decorators.js'; -import type { Filter, Variable, VariableOrProperty } from '../ast.js'; +import type { Variable, VariableOrProperty } from '../ast.js'; import { renderUniLit } from '../../utils/uni-component/uni-component.js'; -import { firstFilterByRef, firstFilterInGroup } from '../ast.js'; export class VariableRefView extends WithDisposable(ShadowlessElement) { static override styles = css` @@ -112,50 +106,3 @@ declare global { 'microsheet-variable-ref-view': VariableRefView; } } -export const popCreateFilter = ( - target: PopupTarget, - props: { - vars: ReadonlySignal; - onSelect: (filter: Filter) => void; - onClose?: () => void; - onBack?: () => void; - } -) => { - popMenu(target, { - options: { - onClose: props.onClose, - title: { - onBack: props.onBack, - text: 'New filter', - }, - items: [ - ...props.vars.value.map(v => - menu.action({ - name: v.name, - prefix: renderUniLit(v.icon, {}), - select: () => { - props.onSelect( - firstFilterByRef(props.vars.value, { - type: 'ref', - name: v.id, - }) - ); - }, - }) - ), - menu.group({ - name: '', - items: [ - menu.action({ - name: 'Add filter group', - prefix: AddCursorIcon(), - select: () => { - props.onSelect(firstFilterInGroup(props.vars.value)); - }, - }), - ], - }), - ], - }, - }); -}; diff --git a/packages/affine/microsheet-data-view/src/core/common/selection.ts b/packages/affine/microsheet-data-view/src/core/common/selection.ts index 94ec5102aff3..ce96a49e8d3e 100644 --- a/packages/affine/microsheet-data-view/src/core/common/selection.ts +++ b/packages/affine/microsheet-data-view/src/core/common/selection.ts @@ -1,7 +1,10 @@ import { BaseSelection, SelectionExtension } from '@blocksuite/block-std'; import { z } from 'zod'; -import type { DataViewSelection, GetDataViewSelection } from '../types.js'; +import type { + GetMicrosheetDataViewSelection, + MicrosheetDataViewSelection, +} from '../types.js'; const TableViewSelectionSchema = z.union([ z.object({ @@ -34,7 +37,7 @@ const TableViewSelectionSchema = z.union([ const MicrosheetSelectionSchema = z.object({ blockId: z.string(), - viewSelection: z.union([TableViewSelectionSchema]), + viewSelection: TableViewSelectionSchema, }); export class MicrosheetSelection extends BaseSelection { @@ -42,7 +45,7 @@ export class MicrosheetSelection extends BaseSelection { static override type = 'microsheet'; - readonly viewSelection: DataViewSelection; + readonly viewSelection: MicrosheetDataViewSelection; get viewId() { return this.viewSelection.viewId; @@ -53,7 +56,7 @@ export class MicrosheetSelection extends BaseSelection { viewSelection, }: { blockId: string; - viewSelection: DataViewSelection; + viewSelection: MicrosheetDataViewSelection; }) { super({ blockId, @@ -66,7 +69,7 @@ export class MicrosheetSelection extends BaseSelection { MicrosheetSelectionSchema.parse(json); return new MicrosheetSelection({ blockId: json.blockId as string, - viewSelection: json.viewSelection as DataViewSelection, + viewSelection: json.viewSelection as MicrosheetDataViewSelection, }); } @@ -77,11 +80,11 @@ export class MicrosheetSelection extends BaseSelection { return this.blockId === other.blockId; } - getSelection( + getSelection( type: T - ): GetDataViewSelection | undefined { + ): GetMicrosheetDataViewSelection | undefined { return this.viewSelection.type === type - ? (this.viewSelection as GetDataViewSelection) + ? (this.viewSelection as GetMicrosheetDataViewSelection) : undefined; } diff --git a/packages/affine/microsheet-data-view/src/core/common/stats/any.ts b/packages/affine/microsheet-data-view/src/core/common/stats/any.ts deleted file mode 100644 index 12c8a26112b0..000000000000 --- a/packages/affine/microsheet-data-view/src/core/common/stats/any.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { StatsFunction } from './type.js'; - -import { tUnknown } from '../../logical/typesystem.js'; - -export const anyTypeStatsFunctions: StatsFunction[] = [ - { - group: 'Count', - menuName: 'Count All', - displayName: 'All', - type: 'count-all', - dataType: tUnknown.create(), - impl: (data: unknown[]) => { - return data.length.toString(); - }, - }, - { - group: 'Count', - menuName: 'Count Values', - displayName: 'Values', - type: 'count-values', - dataType: tUnknown.create(), - impl: (data: unknown[], { meta }) => { - const values = data - .flatMap(v => { - if (meta.config.values) { - return meta.config.values(v); - } - return v; - }) - .filter(v => v != null); - return values.length.toString(); - }, - }, - { - group: 'Count', - menuName: 'Count Unique Values', - displayName: 'Unique Values', - type: 'count-unique-values', - dataType: tUnknown.create(), - impl: (data: unknown[], { meta }) => { - const values = data - .flatMap(v => { - if (meta.config.values) { - return meta.config.values(v); - } - return v; - }) - .filter(v => v != null); - return new Set(values).size.toString(); - }, - }, - { - group: 'Count', - menuName: 'Count Empty', - displayName: 'Empty', - type: 'count-empty', - dataType: tUnknown.create(), - impl: (data, { meta }) => { - const emptyList = data.filter(value => meta.config.isEmpty(value)); - return emptyList.length.toString(); - }, - }, - { - group: 'Count', - menuName: 'Count Not Empty', - displayName: 'Not Empty', - type: 'count-not-empty', - dataType: tUnknown.create(), - impl: (data: unknown[], { meta }) => { - const notEmptyList = data.filter(value => !meta.config.isEmpty(value)); - return notEmptyList.length.toString(); - }, - }, - { - group: 'Percent', - menuName: 'Percent Empty', - displayName: 'Empty', - type: 'percent-empty', - dataType: tUnknown.create(), - impl: (data: unknown[], { meta }) => { - if (data.length === 0) return ''; - const emptyList = data.filter(value => meta.config.isEmpty(value)); - return ((emptyList.length / data.length) * 100).toFixed(2) + '%'; - }, - }, - { - group: 'Percent', - menuName: 'Percent Not Empty', - displayName: 'Not Empty', - type: 'percent-not-empty', - dataType: tUnknown.create(), - impl: (data: unknown[], { meta }) => { - if (data.length === 0) return ''; - const notEmptyList = data.filter(value => !meta.config.isEmpty(value)); - return ((notEmptyList.length / data.length) * 100).toFixed(2) + '%'; - }, - }, -]; diff --git a/packages/affine/microsheet-data-view/src/core/common/stats/checkbox.ts b/packages/affine/microsheet-data-view/src/core/common/stats/checkbox.ts deleted file mode 100644 index 4058535a0b57..000000000000 --- a/packages/affine/microsheet-data-view/src/core/common/stats/checkbox.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { StatsFunction } from './type.js'; - -import { tBoolean } from '../../logical/index.js'; - -export const checkboxTypeStatsFunctions: StatsFunction[] = [ - { - group: 'Count', - type: 'count-values', - dataType: tBoolean.create(), - }, - { - group: 'Count', - type: 'count-unique-values', - dataType: tBoolean.create(), - }, - { - group: 'Count', - type: 'count-empty', - dataType: tBoolean.create(), - menuName: 'Count Unchecked', - displayName: 'Unchecked', - impl: data => { - const emptyList = data.filter(value => !value); - return emptyList.length.toString(); - }, - }, - { - group: 'Count', - type: 'count-not-empty', - dataType: tBoolean.create(), - menuName: 'Count Checked', - displayName: 'Checked', - impl: (data: unknown[]) => { - const notEmptyList = data.filter(value => !!value); - return notEmptyList.length.toString(); - }, - }, - { - group: 'Percent', - type: 'percent-empty', - dataType: tBoolean.create(), - menuName: 'Percent Unchecked', - displayName: 'Unchecked', - impl: (data: unknown[]) => { - if (data.length === 0) return ''; - const emptyList = data.filter(value => !value); - return ((emptyList.length / data.length) * 100).toFixed(2) + '%'; - }, - }, - { - group: 'Percent', - type: 'percent-not-empty', - dataType: tBoolean.create(), - menuName: 'Percent Checked', - displayName: 'Checked', - impl: (data: unknown[]) => { - if (data.length === 0) return ''; - const notEmptyList = data.filter(value => !!value); - return ((notEmptyList.length / data.length) * 100).toFixed(2) + '%'; - }, - }, -]; diff --git a/packages/affine/microsheet-data-view/src/core/common/stats/index.ts b/packages/affine/microsheet-data-view/src/core/common/stats/index.ts deleted file mode 100644 index 4a214f2cb8a0..000000000000 --- a/packages/affine/microsheet-data-view/src/core/common/stats/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { StatsFunction } from './type.js'; - -import { anyTypeStatsFunctions } from './any.js'; -import { checkboxTypeStatsFunctions } from './checkbox.js'; -import { numberStatsFunctions } from './number.js'; - -export const statsFunctions: StatsFunction[] = [ - ...anyTypeStatsFunctions, - ...numberStatsFunctions, - ...checkboxTypeStatsFunctions, -]; diff --git a/packages/affine/microsheet-data-view/src/core/common/stats/number.ts b/packages/affine/microsheet-data-view/src/core/common/stats/number.ts deleted file mode 100644 index 1432803f8241..000000000000 --- a/packages/affine/microsheet-data-view/src/core/common/stats/number.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { StatsFunction } from './type.js'; - -import { tNumber } from '../../logical/data-type.js'; - -export const numberStatsFunctions: StatsFunction[] = [ - { - group: 'More options', - menuName: 'Sum', - type: 'sum', - dataType: tNumber.create(), - impl: (data: number[]) => { - const numbers = withoutNull(data); - if (numbers.length === 0) { - return 'None'; - } - return numbers.reduce((a, b) => a + b, 0).toString(); - }, - }, - { - group: 'More options', - menuName: 'Average', - type: 'average', - dataType: tNumber.create(), - impl: (data: number[]) => { - const numbers = withoutNull(data); - if (numbers.length === 0) { - return 'None'; - } - return (numbers.reduce((a, b) => a + b, 0) / numbers.length).toString(); - }, - }, - { - group: 'More options', - menuName: 'Median', - type: 'median', - dataType: tNumber.create(), - impl: (data: number[]) => { - const arr = withoutNull(data).sort((a, b) => a - b); - let result = 0; - if (arr.length % 2 === 1) { - result = arr[(arr.length - 1) / 2]; - } else { - const index = arr.length / 2; - result = (arr[index] + arr[index - 1]) / 2; - } - return result?.toString() ?? 'None'; - }, - }, - { - group: 'More options', - menuName: 'Min', - type: 'min', - dataType: tNumber.create(), - impl: (data: number[]) => { - let min: number | null = null; - for (const num of data) { - if (num != null) { - if (min == null) { - min = num; - } else { - min = Math.min(min, num); - } - } - } - return min?.toString() ?? 'None'; - }, - }, - { - group: 'More options', - menuName: 'Max', - type: 'max', - dataType: tNumber.create(), - impl: (data: number[]) => { - let max: number | null = null; - for (const num of data) { - if (num != null) { - if (max == null) { - max = num; - } else { - max = Math.max(max, num); - } - } - } - return max?.toString() ?? 'None'; - }, - }, - { - group: 'More options', - menuName: 'Range', - type: 'range', - dataType: tNumber.create(), - impl: (data: number[]) => { - let min: number | null = null; - let max: number | null = null; - for (const num of data) { - if (num != null) { - if (max == null) { - max = num; - } else { - max = Math.max(max, num); - } - if (min == null) { - min = num; - } else { - min = Math.min(min, num); - } - } - } - if (min == null || max == null) { - return 'None'; - } - return (max - min).toString(); - }, - }, -]; -const withoutNull = (arr: number[]) => arr.filter(v => v != null); diff --git a/packages/affine/microsheet-data-view/src/core/common/stats/type.ts b/packages/affine/microsheet-data-view/src/core/common/stats/type.ts deleted file mode 100644 index b58a19b114cb..000000000000 --- a/packages/affine/microsheet-data-view/src/core/common/stats/type.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { TType } from '../../logical/typesystem.js'; -import type { PropertyMetaConfig } from '../../property/property-config.js'; - -export type StatsFunction = { - group: string; - type: string; - dataType: TType; - menuName?: string; - displayName?: string; - impl?: ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - data: any[], - info: { - meta: PropertyMetaConfig; - } - ) => string; -}; diff --git a/packages/affine/microsheet-data-view/src/core/data-view.ts b/packages/affine/microsheet-data-view/src/core/data-view.ts index 16e4fb951ec3..959230e16945 100644 --- a/packages/affine/microsheet-data-view/src/core/data-view.ts +++ b/packages/affine/microsheet-data-view/src/core/data-view.ts @@ -11,7 +11,10 @@ import { createRef, ref } from 'lit/directives/ref.js'; import { html } from 'lit/static-html.js'; import type { DataSource } from './common/data-source/base.js'; -import type { DataViewSelection, DataViewSelectionState } from './types.js'; +import type { + MicrosheetDataViewSelection, + MicrosheetDataViewSelectionState, +} from './types.js'; import type { DataViewExpose, DataViewProps } from './view/types.js'; import type { SingleView } from './view-manager/single-view.js'; @@ -20,8 +23,8 @@ import { renderUniLit } from './utils/uni-component/index.js'; type ViewProps = { view: SingleView; - selection$: ReadonlySignal; - setSelection: (selection?: DataViewSelectionState) => void; + selection$: ReadonlySignal; + setSelection: (selection?: MicrosheetDataViewSelectionState) => void; bindHotkey: DataViewProps['bindHotkey']; handleEvent: DataViewProps['handleEvent']; }; @@ -30,18 +33,9 @@ export type DataViewRendererConfig = { bindHotkey: DataViewProps['bindHotkey']; handleEvent: DataViewProps['handleEvent']; virtualPadding$: DataViewProps['virtualPadding$']; - selection$: ReadonlySignal; - setSelection: (selection: DataViewSelection | undefined) => void; + selection$: ReadonlySignal; + setSelection: (selection: MicrosheetDataViewSelection | undefined) => void; dataSource: DataSource; - detailPanelConfig: { - openDetailPanel: ( - target: HTMLElement, - data: { - view: SingleView; - rowId: string; - } - ) => Promise; - }; headerWidget: DataViewProps['headerWidget']; onDrag?: DataViewProps['onDrag']; std: BlockStdScope; @@ -114,22 +108,6 @@ export class DataViewRenderer extends SignalWatcher( this.view?.expose.focusFirstCell(); }; - openDetailPanel = (ops: { - view: SingleView; - rowId: string; - onClose?: () => void; - }) => { - const openDetailPanel = this.config.detailPanelConfig.openDetailPanel; - if (openDetailPanel) { - openDetailPanel(this, { - view: ops.view, - rowId: ops.rowId, - }) - .catch(console.error) - .finally(ops.onClose); - } - }; - get view() { return this._view.value; } diff --git a/packages/affine/microsheet-data-view/src/core/index.ts b/packages/affine/microsheet-data-view/src/core/index.ts index 1bd6c4c36c75..4fc71153d8bb 100644 --- a/packages/affine/microsheet-data-view/src/core/index.ts +++ b/packages/affine/microsheet-data-view/src/core/index.ts @@ -3,7 +3,7 @@ export * from './common/index.js'; export { DataView } from './data-view.js'; export * from './logical/index.js'; export * from './property/index.js'; -export type { DataViewSelection } from './types.js'; +export type { MicrosheetDataViewSelection } from './types.js'; export * from './types.js'; export * from './utils/index.js'; export * from './view/index.js'; diff --git a/packages/affine/microsheet-data-view/src/core/logical/data-type.ts b/packages/affine/microsheet-data-view/src/core/logical/data-type.ts index 2b29eb997bda..eedb423a72ff 100644 --- a/packages/affine/microsheet-data-view/src/core/logical/data-type.ts +++ b/packages/affine/microsheet-data-view/src/core/logical/data-type.ts @@ -1,11 +1,5 @@ -import type { SelectTag } from '../utils/tags/multi-tag-select.js'; - import { typesystem } from './typesystem.js'; -export const tNumber = typesystem.defineData<{ value: number }>({ - name: 'Number', - supers: [], -}); export const tString = typesystem.defineData<{ value: string }>({ name: 'String', supers: [], @@ -14,31 +8,3 @@ export const tRichText = typesystem.defineData<{ value: string }>({ name: 'RichText', supers: [tString], }); -export const tBoolean = typesystem.defineData<{ value: boolean }>({ - name: 'Boolean', - supers: [], -}); -export const tDate = typesystem.defineData<{ value: number }>({ - name: 'Date', - supers: [], -}); -export const tURL = typesystem.defineData({ - name: 'URL', - supers: [tString], -}); -export const tImage = typesystem.defineData({ - name: 'Image', - supers: [], -}); -export const tEmail = typesystem.defineData({ - name: 'Email', - supers: [tString], -}); -export const tPhone = typesystem.defineData({ - name: 'Phone', - supers: [tString], -}); -export const tTag = typesystem.defineData<{ tags: SelectTag[] }>({ - name: 'Tag', - supers: [], -}); diff --git a/packages/affine/microsheet-data-view/src/core/logical/eval-filter.ts b/packages/affine/microsheet-data-view/src/core/logical/eval-filter.ts deleted file mode 100644 index be1b9410086e..000000000000 --- a/packages/affine/microsheet-data-view/src/core/logical/eval-filter.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { Filter, Value, VariableOrProperty } from '../common/ast.js'; - -import { filterMatcher } from '../../widget-presets/filter/matcher/matcher.js'; -import { propertyMatcher } from './property-matcher.js'; - -const evalRef = ( - ref: VariableOrProperty, - row: Record -): unknown => { - if (ref.type === 'ref') { - return row[ref.name]; - } - const value = evalRef(ref.ref, row); - const fn = propertyMatcher.findData(v => v.name === ref.propertyFuncName); - try { - return fn?.impl(value); - } catch (e) { - console.error(e); - return; - } -}; - -const evalValue = (value: Value, _row: Record): unknown => { - return value.value; - // TODO - // switch (value.type) { - // case "ref": - // return evalRef(value, row) - // case "literal": - // return value.value; - // } -}; -export const evalFilter = ( - filterGroup: Filter, - row: Record -): boolean => { - const evalF = (filter: Filter): boolean => { - if (filter.type === 'filter') { - const value = evalRef(filter.left, row); - const func = filterMatcher.findData(v => v.name === filter.function); - const args = filter.args.map(value => evalValue(value, row)); - try { - if ((func?.impl.length ?? 0) > args.length + 1) { - // skip - return true; - } - const impl = func?.impl(value, ...args); - return impl ?? true; - } catch (e) { - console.error(e); - return true; - } - } else if (filter.type === 'group') { - if (filter.op === 'and') { - return filter.conditions.every(f => evalF(f)); - } else if (filter.op === 'or') { - return filter.conditions.some(f => evalF(f)); - } - } - return true; - }; - // console.log(evalF(filterGroup)) - return evalF(filterGroup); -}; diff --git a/packages/affine/microsheet-data-view/src/core/logical/property-matcher.ts b/packages/affine/microsheet-data-view/src/core/logical/property-matcher.ts deleted file mode 100644 index 74face0f78ce..000000000000 --- a/packages/affine/microsheet-data-view/src/core/logical/property-matcher.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { TFunction } from './typesystem.js'; - -import { tDate, tNumber, tString } from './data-type.js'; -import { Matcher, MatcherCreator } from './matcher.js'; -import { tArray, tFunction, tUnknown, typesystem } from './typesystem.js'; - -type PropertyData = { - name: string; - impl: (...args: unknown[]) => unknown; -}; -const propertyMatcherCreator = new MatcherCreator(); -const propertyMatchers = [ - propertyMatcherCreator.createMatcher( - tFunction({ - args: [tString.create()], - rt: tNumber.create(), - }), - { - name: 'Length', - impl: value => { - if (typeof value !== 'string') { - return 0; - } - return value.length; - }, - } - ), - propertyMatcherCreator.createMatcher( - tFunction({ - args: [tDate.create()], - rt: tNumber.create(), - }), - { - name: 'Day of month', - impl: value => { - if (typeof value !== 'number') { - return 0; - } - return new Date(value).getDate(); - }, - } - ), - propertyMatcherCreator.createMatcher( - tFunction({ - args: [tDate.create()], - rt: tNumber.create(), - }), - { - name: 'Day of week', - impl: value => { - if (typeof value !== 'number') { - return 0; - } - return new Date(value).getDay(); - }, - } - ), - propertyMatcherCreator.createMatcher( - tFunction({ - args: [tDate.create()], - rt: tNumber.create(), - }), - { - name: 'Month of year', - impl: value => { - if (typeof value !== 'number') { - return 0; - } - return new Date(value).getMonth() + 1; - }, - } - ), - propertyMatcherCreator.createMatcher( - tFunction({ - args: [tArray(tUnknown.create())], - rt: tNumber.create(), - }), - { - name: 'Size', - impl: value => { - if (!Array.isArray(value)) { - return 0; - } - return value.length; - }, - } - ), -]; -export const propertyMatcher = new Matcher( - propertyMatchers, - (type, target) => { - if (type.type !== 'function') { - return false; - } - const staticType = typesystem.subst( - Object.fromEntries(type.typeVars?.map(v => [v.name, v.bound]) ?? []), - type - ); - const firstArg = staticType.args[0]; - return firstArg && typesystem.isSubtype(firstArg, target); - } -); diff --git a/packages/affine/microsheet-data-view/src/core/types.ts b/packages/affine/microsheet-data-view/src/core/types.ts index 00c36e4abd23..fa804cc79280 100644 --- a/packages/affine/microsheet-data-view/src/core/types.ts +++ b/packages/affine/microsheet-data-view/src/core/types.ts @@ -1,15 +1,17 @@ import type { TableViewSelectionWithType } from '../view-presets/table/types.js'; -export type DataViewSelection = TableViewSelectionWithType; -export type GetDataViewSelection< - K extends DataViewSelection['type'], - T = DataViewSelection, +export type MicrosheetDataViewSelection = TableViewSelectionWithType; +export type GetMicrosheetDataViewSelection< + K extends MicrosheetDataViewSelection['type'], + T = MicrosheetDataViewSelection, > = T extends { type: K; } ? T : never; -export type DataViewSelectionState = DataViewSelection | undefined; +export type MicrosheetDataViewSelectionState = + | MicrosheetDataViewSelection + | undefined; export type PropertyDataUpdater< Data extends Record = Record, > = (data: Data) => Partial; diff --git a/packages/affine/microsheet-data-view/src/core/utils/index.ts b/packages/affine/microsheet-data-view/src/core/utils/index.ts index 37057f593dbd..09090f1e8c10 100644 --- a/packages/affine/microsheet-data-view/src/core/utils/index.ts +++ b/packages/affine/microsheet-data-view/src/core/utils/index.ts @@ -1,3 +1,2 @@ -export * from './tags/index.js'; export * from './uni-component/index.js'; export * from './uni-icon.js'; diff --git a/packages/affine/microsheet-data-view/src/core/utils/tags/colors.ts b/packages/affine/microsheet-data-view/src/core/utils/tags/colors.ts deleted file mode 100644 index 2f407ae88d7c..000000000000 --- a/packages/affine/microsheet-data-view/src/core/utils/tags/colors.ts +++ /dev/null @@ -1,64 +0,0 @@ -export type SelectOptionColor = { - color: string; - name: string; -}; - -export const selectOptionColors: SelectOptionColor[] = [ - { - color: 'var(--affine-tag-red)', - name: 'Red', - }, - { - color: 'var(--affine-tag-pink)', - name: 'Pink', - }, - { - color: 'var(--affine-tag-orange)', - name: 'Orange', - }, - { - color: 'var(--affine-tag-yellow)', - name: 'Yellow', - }, - { - color: 'var(--affine-tag-green)', - name: 'Green', - }, - { - color: 'var(--affine-tag-teal)', - name: 'Teal', - }, - { - color: 'var(--affine-tag-blue)', - name: 'Blue', - }, - { - color: 'var(--affine-tag-purple)', - name: 'Purple', - }, - { - color: 'var(--affine-tag-gray)', - name: 'Gray', - }, - { - color: 'var(--affine-tag-white)', - name: 'White', - }, -]; - -/** select tag color poll */ -const selectTagColorPoll = selectOptionColors.map(color => color.color); - -function tagColorHelper() { - let colors = [...selectTagColorPoll]; - return () => { - if (colors.length === 0) { - colors = [...selectTagColorPoll]; - } - const index = Math.floor(Math.random() * colors.length); - const color = colors.splice(index, 1)[0]; - return color; - }; -} - -export const getTagColor = tagColorHelper(); diff --git a/packages/affine/microsheet-data-view/src/core/utils/tags/index.ts b/packages/affine/microsheet-data-view/src/core/utils/tags/index.ts deleted file mode 100644 index acb9a8bbf2c9..000000000000 --- a/packages/affine/microsheet-data-view/src/core/utils/tags/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './colors.js'; -export * from './multi-tag-select.js'; -export * from './multi-tag-view.js'; diff --git a/packages/affine/microsheet-data-view/src/core/utils/tags/multi-tag-select.ts b/packages/affine/microsheet-data-view/src/core/utils/tags/multi-tag-select.ts deleted file mode 100644 index 98654c6a8474..000000000000 --- a/packages/affine/microsheet-data-view/src/core/utils/tags/multi-tag-select.ts +++ /dev/null @@ -1,503 +0,0 @@ -import { - createPopup, - menu, - popMenu, - type PopupTarget, - popupTargetFromElement, -} from '@blocksuite/affine-components/context-menu'; -import { rangeWrap } from '@blocksuite/affine-shared/utils'; -import { ShadowlessElement } from '@blocksuite/block-std'; -import { WithDisposable } from '@blocksuite/global/utils'; -import { - CloseIcon, - DeleteIcon, - MoreHorizontalIcon, - PlusIcon, -} from '@blocksuite/icons/lit'; -import { nanoid } from '@blocksuite/store'; -import { flip, offset } from '@floating-ui/dom'; -import { property, query, state } from 'lit/decorators.js'; -import { classMap } from 'lit/directives/class-map.js'; -import { repeat } from 'lit/directives/repeat.js'; -import { styleMap } from 'lit/directives/style-map.js'; -import { html } from 'lit/static-html.js'; - -import { stopPropagation } from '../event.js'; -import { getTagColor, selectOptionColors } from './colors.js'; -import { styles } from './styles.js'; - -export type SelectTag = { - id: string; - color: string; - value: string; - parentId?: string; -}; - -type RenderOption = { - value: string; - id: string; - color: string; - isCreate: boolean; - group: SelectTag[]; - select: () => void; -}; - -export class MultiTagSelect extends WithDisposable(ShadowlessElement) { - static override styles = styles; - - private _clickItemOption = (e: MouseEvent, id: string) => { - e.stopPropagation(); - const option = this.options.find(v => v.id === id); - if (!option) { - return; - } - popMenu(popupTargetFromElement(e.currentTarget as HTMLElement), { - options: { - items: [ - menu.input({ - initialValue: option.value, - onComplete: text => { - this.changeTag({ - ...option, - value: text, - }); - }, - }), - menu.action({ - name: 'Delete', - prefix: DeleteIcon(), - class: 'delete-item', - select: () => { - this.deleteTag(id); - }, - }), - menu.group({ - name: 'color', - items: selectOptionColors.map(item => { - const styles = styleMap({ - backgroundColor: item.color, - borderRadius: '50%', - width: '20px', - height: '20px', - }); - return menu.action({ - name: item.name, - prefix: html`
`, - isSelected: option.color === item.color, - select: () => { - this.changeTag({ - ...option, - color: item.color, - }); - }, - }); - }), - }), - ], - }, - }); - }; - - private _createOption = () => { - const value = this.text.trim(); - if (value === '') return; - const groupInfo = this.getGroupInfoByFullName(value); - if (!groupInfo) { - return; - } - const name = groupInfo.name; - const tagColor = this.color; - this.clearColor(); - const newSelect: SelectTag = { - id: nanoid(), - value: name, - color: tagColor, - parentId: groupInfo.parent?.id, - }; - this.newTags([newSelect]); - const newValue = this.isSingleMode - ? [newSelect.id] - : [...this.value, newSelect.id]; - this.onChange(newValue); - this.text = ''; - if (this.isSingleMode) { - this.editComplete(); - } - }; - - private _currentColor: string | undefined = undefined; - - private _onDeleteSelected = (selectedValue: string[], value: string) => { - const filteredValue = selectedValue.filter(item => item !== value); - this.onChange(filteredValue); - }; - - private _onInput = (event: KeyboardEvent) => { - this.text = (event.target as HTMLInputElement).value; - }; - - private _onInputKeydown = (event: KeyboardEvent) => { - event.stopPropagation(); - const inputValue = this.text.trim(); - if (event.key === 'Backspace' && inputValue === '') { - this._onDeleteSelected(this.value, this.value[this.value.length - 1]); - } else if (event.key === 'Enter' && !event.isComposing) { - this.selectedTag?.select(); - } else if (event.key === 'ArrowUp') { - event.preventDefault(); - this.setSelectedOption(this.selectedIndex - 1); - } else if (event.key === 'ArrowDown') { - event.preventDefault(); - this.setSelectedOption(this.selectedIndex + 1); - } else if (event.key === 'Escape') { - this.editComplete(); - } else if (event.key === 'Tab') { - event.preventDefault(); - const selectTag = this.selectedTag; - if (selectTag) { - this.text = this.getTagFullName(selectTag, selectTag.group); - } - } - }; - - private _onSelect = (id: string) => { - const isExist = this.value.some(item => item === id); - if (isExist) { - // this.editComplete(); - return; - } - - const isSelected = this.value.indexOf(id) > -1; - if (!isSelected) { - const newValue = this.isSingleMode ? [id] : [...this.value, id]; - this.onChange(newValue); - if (this.isSingleMode) { - setTimeout(() => { - this.editComplete(); - }, 4); - } - } - this.text = ''; - }; - - private filteredOptions: Array = []; - - changeTag = (tag: SelectTag) => { - this.onOptionsChange(this.options.map(v => (v.id === tag.id ? tag : v))); - }; - - deleteTag = (id: string) => { - this.onOptionsChange( - this.options - .filter(v => v.id !== id) - .map(v => ({ - ...v, - parentId: v.parentId === id ? undefined : v.parentId, - })) - ); - }; - - newTags = (tags: SelectTag[]) => { - this.onOptionsChange([...tags, ...this.options]); - }; - - private get color() { - if (!this._currentColor) { - this._currentColor = getTagColor(); - } - return this._currentColor; - } - - get isSingleMode() { - return this.mode === 'single'; - } - - private get selectedTag() { - return this.filteredOptions[this.selectedIndex]; - } - - private _filterOptions() { - const map = this.optionsIdMap(); - let matched = false; - const options: RenderOption[] = this.options - .map(v => ({ - ...v, - group: this.getTagGroup(v, map), - })) - .filter(item => { - if (!this.text) { - return true; - } - return this.getTagFullName(item, item.group) - .toLocaleLowerCase() - .includes(this.text.toLocaleLowerCase()); - }) - .map(v => { - const fullName = this.getTagFullName(v, v.group); - if (fullName === this.text) { - matched = true; - } - return { - ...v, - isCreate: false, - select: () => this._onSelect(v.id), - }; - }); - if (this.text && !matched) { - options.push({ - id: 'create', - color: this.color, - value: this.text, - isCreate: true, - group: [], - select: this._createOption, - }); - } - return options; - } - - private clearColor() { - this._currentColor = undefined; - } - - private getGroupInfoByFullName(name: string) { - const strings = name.split('/'); - const names = strings.slice(0, -1); - const result: SelectTag[] = []; - for (const text of names) { - const parent = result[result.length - 1]; - const tag = this.options.find( - v => v.parentId === parent?.id && v.value === text - ); - if (!tag) { - return; - } - result.push(tag); - } - return { - name: strings[strings.length - 1], - group: result, - parent: result[result.length - 1], - }; - } - - private getTagFullName(tag: SelectTag, group: SelectTag[]) { - return [...group, tag].map(v => v.value).join('/'); - } - - private getTagGroup(tag: SelectTag, map = this.optionsIdMap()): SelectTag[] { - const result = []; - let parentId = tag.parentId; - while (parentId) { - const parent = map[parentId]; - result.unshift(parent); - parentId = parent.parentId; - } - return result; - } - - private optionsIdMap() { - return Object.fromEntries(this.options.map(v => [v.id, v])); - } - - private setSelectedOption(index: number) { - this.selectedIndex = rangeWrap(index, 0, this.filteredOptions.length); - } - - protected override firstUpdated() { - requestAnimationFrame(() => { - this._selectInput.focus(); - }); - this._disposables.addFromEvent(this, 'click', () => { - this._selectInput.focus(); - }); - - this._disposables.addFromEvent(this._selectInput, 'copy', e => { - e.stopPropagation(); - }); - this._disposables.addFromEvent(this._selectInput, 'cut', e => { - e.stopPropagation(); - }); - } - - override render() { - this.filteredOptions = this._filterOptions(); - this.setSelectedOption(this.selectedIndex); - const selectedTag = this.value; - const map = new Map(this.options.map(v => [v.id, v])); - return html` -
-
- ${selectedTag.map(id => { - const option = map.get(id); - if (!option) { - return; - } - const style = styleMap({ - backgroundColor: option.color, - }); - return html`
-
${option.value}
- ${CloseIcon()} -
`; - })} - -
-
-
- Select tag or create one -
- ${repeat( - this.filteredOptions, - select => select.id, - (select, index) => { - const isSelected = index === this.selectedIndex; - const mouseenter = () => { - this.setSelectedOption(index); - }; - const classes = classMap({ - 'select-option': true, - selected: isSelected, - }); - const style = styleMap({ - backgroundColor: select.color, - }); - const clickOption = (e: MouseEvent) => - this._clickItemOption(e, select.id); - return html` -
-
- ${select.isCreate - ? html`
- Create ${PlusIcon()} -
` - : ''} -
-
- ${select.group.map((v, i) => { - const style = styleMap({ - backgroundColor: v.color, - }); - return html`${i === 0 - ? '' - : html`/`}${v.value}`; - })} -
-
-
- ${select.value} -
-
-
-
- ${!select.isCreate - ? html`
- ${MoreHorizontalIcon()} -
` - : null} -
- `; - } - )} -
-
- `; - } - - @query('.select-input') - private accessor _selectInput!: HTMLInputElement; - - @property({ attribute: false }) - accessor editComplete!: () => void; - - @property() - accessor mode: 'multi' | 'single' = 'multi'; - - @property({ attribute: false }) - accessor onChange!: (value: string[]) => void; - - @property({ attribute: false }) - accessor onOptionsChange!: (options: SelectTag[]) => void; - - @property({ attribute: false }) - accessor options: SelectTag[] = []; - - @state() - private accessor selectedIndex = 0; - - @state() - private accessor text = ''; - - @property({ attribute: false }) - accessor value: string[] = []; -} - -declare global { - interface HTMLElementTagNameMap { - 'affine-microsheet-multi-tag-select': MultiTagSelect; - } -} - -export const popTagSelect = ( - target: PopupTarget, - ops: { - mode?: 'single' | 'multi'; - value: string[]; - onChange: (value: string[]) => void; - options: SelectTag[]; - onOptionsChange: (options: SelectTag[]) => void; - onComplete?: () => void; - minWidth?: number; - container?: HTMLElement; - } -) => { - const component = new MultiTagSelect(); - if (ops.mode) { - component.mode = ops.mode; - } - const width = target.targetRect.getBoundingClientRect().width; - component.style.width = `${Math.max(ops.minWidth ?? width, width)}px`; - component.value = ops.value; - component.onChange = tags => { - ops.onChange(tags); - component.value = tags; - }; - component.options = ops.options; - component.onOptionsChange = options => { - ops.onOptionsChange(options); - component.options = options; - }; - component.editComplete = () => { - ops.onComplete?.(); - remove(); - }; - const remove = createPopup(target, component, { - onClose: ops.onComplete, - middleware: [flip(), offset({ mainAxis: -28, crossAxis: 112 })], - container: ops.container, - }); - return remove; -}; diff --git a/packages/affine/microsheet-data-view/src/core/utils/tags/multi-tag-view.ts b/packages/affine/microsheet-data-view/src/core/utils/tags/multi-tag-view.ts deleted file mode 100644 index 5ebf37e38977..000000000000 --- a/packages/affine/microsheet-data-view/src/core/utils/tags/multi-tag-view.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { ShadowlessElement } from '@blocksuite/block-std'; -import { WithDisposable } from '@blocksuite/global/utils'; -import { css } from 'lit'; -import { property, query } from 'lit/decorators.js'; -import { repeat } from 'lit/directives/repeat.js'; -import { styleMap } from 'lit/directives/style-map.js'; -import { html } from 'lit/static-html.js'; - -import type { SelectTag } from './multi-tag-select.js'; - -export class MultiTagView extends WithDisposable(ShadowlessElement) { - static override styles = css` - affine-microsheet-multi-tag-view { - display: flex; - align-items: center; - width: 100%; - height: 100%; - min-height: 22px; - } - - .affine-select-cell-container * { - box-sizing: border-box; - } - - .affine-select-cell-container { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 6px; - width: 100%; - font-size: var(--affine-font-sm); - } - - .affine-select-cell-container .select-selected { - height: 22px; - font-size: 14px; - line-height: 22px; - padding: 0 8px; - border-radius: 4px; - white-space: nowrap; - background: var(--affine-tag-white); - overflow: hidden; - text-overflow: ellipsis; - } - `; - - override render() { - const values = this.value; - const map = new Map(this.options?.map(v => [v.id, v])); - return html` -
- ${repeat(values, id => { - const option = map.get(id); - if (!option) { - return; - } - const style = styleMap({ - backgroundColor: option.color, - }); - return html`${option.value}`; - })} -
- `; - } - - @property({ attribute: false }) - accessor options: SelectTag[] = []; - - @query('.affine-select-cell-container') - accessor selectContainer!: HTMLElement; - - @property({ attribute: false }) - accessor value: string[] = []; -} - -declare global { - interface HTMLElementTagNameMap { - 'affine-microsheet-multi-tag-view': MultiTagView; - } -} diff --git a/packages/affine/microsheet-data-view/src/core/utils/tags/styles.ts b/packages/affine/microsheet-data-view/src/core/utils/tags/styles.ts deleted file mode 100644 index 334936d9bf26..000000000000 --- a/packages/affine/microsheet-data-view/src/core/utils/tags/styles.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { baseTheme } from '@toeverything/theme'; -import { css, unsafeCSS } from 'lit'; - -export const styles = css` - affine-microsheet-multi-tag-select { - position: absolute; - z-index: 2; - border: 1px solid var(--affine-border-color); - border-radius: 8px; - background: var(--affine-background-primary-color); - box-shadow: var(--affine-shadow-2); - font-family: var(--affine-font-family); - min-width: 300px; - max-width: 720px; - } - - .affine-select-cell-select { - font-size: var(--affine-font-sm); - } - - @media print { - .affine-select-cell-select { - display: none; - } - } - - .select-input-container { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 6px; - min-height: 44px; - padding: 10px 8px; - background: var(--affine-hover-color); - border-radius: 8px; - } - - .select-input { - flex: 1 1 0; - height: 24px; - border: none; - font-family: ${unsafeCSS(baseTheme.fontSansFamily)}; - color: inherit; - background: transparent; - line-height: 24px; - } - - .select-input:focus { - outline: none; - } - - .select-input::placeholder { - color: var(--affine-placeholder-color); - } - - .select-option-container { - padding: 8px; - color: var(--affine-black-90); - fill: var(--affine-black-90); - max-height: 400px; - overflow-y: auto; - } - - .select-option-container-header { - padding: 0px 4px 8px 4px; - color: var(--affine-black-60); - font-size: 12px; - user-select: none; - } - - .select-input-container .select-selected { - display: flex; - align-items: center; - padding: 2px 10px; - gap: 10px; - height: 28px; - background: var(--affine-tag-white); - border-radius: 4px; - color: var(--affine-black-90); - background: var(--affine-tertiary-color); - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - } - - .select-selected-text { - width: calc(100% - 16px); - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - } - - .select-selected > .close-icon { - display: flex; - align-items: center; - } - - .select-selected > .close-icon:hover { - cursor: pointer; - } - - .select-selected > .close-icon > svg { - fill: var(--affine-black-90); - } - - .select-option-new { - display: flex; - flex-direction: row; - align-items: center; - height: 36px; - padding: 4px; - gap: 5px; - border-radius: 4px; - background: var(--affine-selected-color); - } - - .select-option-new-text { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - height: 28px; - padding: 2px 10px; - border-radius: 4px; - background: var(--affine-tag-red); - } - - .select-option-new-icon { - display: flex; - align-items: center; - gap: 6px; - height: 28px; - color: var(--affine-text-primary-color); - margin-right: 8px; - } - - .select-option-new-icon svg { - width: 16px; - height: 16px; - } - - .select-option { - position: relative; - display: flex; - justify-content: space-between; - align-items: center; - padding: 4px; - border-radius: 4px; - margin-bottom: 4px; - cursor: pointer; - } - - .select-option.selected { - background: var(--affine-hover-color); - } - - .select-option-text-container { - width: 100%; - overflow: hidden; - display: flex; - } - - .select-option-group-name { - font-size: 9px; - padding: 0 2px; - border-radius: 2px; - } - - .select-option-name { - padding: 4px 8px; - border-radius: 4px; - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - } - - .select-option-icon { - display: flex; - justify-content: center; - align-items: center; - width: 28px; - height: 28px; - border-radius: 3px; - cursor: pointer; - opacity: 0; - } - - .select-option.selected .select-option-icon { - opacity: 1; - } - - .select-option-icon:hover { - background: var(--affine-hover-color); - } - - .select-option-icon svg { - width: 16px; - height: 16px; - pointer-events: none; - } -`; diff --git a/packages/affine/microsheet-data-view/src/core/view-manager/property.ts b/packages/affine/microsheet-data-view/src/core/view-manager/property.ts index b12beda4f4e6..7919f0fa38d6 100644 --- a/packages/affine/microsheet-data-view/src/core/view-manager/property.ts +++ b/packages/affine/microsheet-data-view/src/core/view-manager/property.ts @@ -115,10 +115,6 @@ export abstract class PropertyBase< ); } - get typeSet(): undefined | ((type: string) => void) { - return type => this.view.propertyTypeSet(this.id, type); - } - constructor( public view: SingleView, public propertyId: string diff --git a/packages/affine/microsheet-data-view/src/core/view-manager/single-view.ts b/packages/affine/microsheet-data-view/src/core/view-manager/single-view.ts index 214667f52c96..ec5bbfc1a9ab 100644 --- a/packages/affine/microsheet-data-view/src/core/view-manager/single-view.ts +++ b/packages/affine/microsheet-data-view/src/core/view-manager/single-view.ts @@ -2,11 +2,10 @@ import type { InsertToPosition } from '@blocksuite/affine-shared/utils'; import { computed, type ReadonlySignal, signal } from '@preact/signals-core'; -import type { FilterGroup, Variable } from '../common/ast.js'; +import type { Variable } from '../common/ast.js'; import type { DataViewContextKey } from '../common/data-source/context.js'; import type { TType } from '../logical/typesystem.js'; import type { PropertyMetaConfig } from '../property/property-config.js'; -import type { MicrosheetFlags } from '../types.js'; import type { UniComponent } from '../utils/uni-component/index.js'; import type { DataViewDataType, ViewMeta } from '../view/data-view.js'; import type { Property } from './property.js'; @@ -44,13 +43,8 @@ export interface SingleView< readonly detailProperties$: ReadonlySignal; readonly rows$: ReadonlySignal; - readonly filter$: ReadonlySignal; - filterSet(filter: FilterGroup): void; - readonly vars$: ReadonlySignal; - readonly featureFlags$: ReadonlySignal; - cellValueGet(rowId: string, propertyId: string): unknown; cellValueSet(rowId: string, propertyId: string, value: unknown): void; @@ -93,7 +87,6 @@ export interface SingleView< propertyNameSet(propertyId: string, name: string): void; propertyTypeGet(propertyId: string): string | undefined; - propertyTypeSet(propertyId: string, type: string): void; propertyHideGet(propertyId: string): boolean; propertyHideSet(propertyId: string, hide: boolean): void; @@ -126,12 +119,6 @@ export abstract class SingleViewBase< abstract detailProperties$: ReadonlySignal; - abstract filter$: ReadonlySignal; - - filterVisible$ = computed(() => { - return (this.filter$.value?.conditions.length ?? 0) > 0; - }); - abstract mainProperties$: ReadonlySignal; name$: ReadonlySignal = computed(() => { @@ -151,7 +138,7 @@ export abstract class SingleViewBase< abstract readonly$: ReadonlySignal; rows$ = computed(() => { - return this.filteredRows(this.searchString.value); + return this.dataSource.rows$.value; }); vars$ = computed(() => { @@ -171,10 +158,6 @@ export abstract class SingleViewBase< return this.manager.dataSource; } - get featureFlags$() { - return this.dataSource.featureFlags$; - } - get meta() { return this.dataSource.viewMetaGet(this.type); } @@ -190,24 +173,6 @@ export abstract class SingleViewBase< public id: string ) {} - private filteredRows(searchString: string): string[] { - return this.dataSource.rows$.value.filter(id => { - if (searchString) { - const containsSearchString = this.propertyIds$.value.some( - propertyId => { - return this.cellStringValueGet(id, propertyId) - ?.toLowerCase() - .includes(searchString?.toLowerCase()); - } - ); - if (!containsSearchString) { - return false; - } - } - return this.isShow(id); - }); - } - cellGet(rowId: string, propertyId: string): Cell { return new CellBase(this, propertyId, rowId); } @@ -283,8 +248,6 @@ export abstract class SingleViewBase< this.manager.viewDuplicate(this.id); } - abstract filterSet(filter: FilterGroup): void; - IconGet(type: string): UniComponent | undefined { return this.dataSource.propertyMetaGet(type).renderer.icon; } @@ -395,10 +358,6 @@ export abstract class SingleViewBase< return this.dataSource.propertyTypeGet(propertyId); } - propertyTypeSet(propertyId: string, type: string): void { - this.dataSource.propertyTypeSet(propertyId, type); - } - rowAdd(insertPosition: InsertToPosition | number): string { return this.dataSource.rowAdd(insertPosition); } diff --git a/packages/affine/microsheet-data-view/src/core/view/data-view-base.ts b/packages/affine/microsheet-data-view/src/core/view/data-view-base.ts index c9a0bcb8f7d8..244a57e490a6 100644 --- a/packages/affine/microsheet-data-view/src/core/view/data-view-base.ts +++ b/packages/affine/microsheet-data-view/src/core/view/data-view-base.ts @@ -2,13 +2,13 @@ import { ShadowlessElement } from '@blocksuite/block-std'; import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; import { property } from 'lit/decorators.js'; -import type { DataViewSelection } from '../types.js'; +import type { MicrosheetDataViewSelection } from '../types.js'; import type { SingleView } from '../view-manager/single-view.js'; import type { DataViewExpose, DataViewProps } from './types.js'; export abstract class DataViewBase< T extends SingleView = SingleView, - Selection extends DataViewSelection = DataViewSelection, + Selection extends MicrosheetDataViewSelection = MicrosheetDataViewSelection, > extends SignalWatcher(WithDisposable(ShadowlessElement)) { abstract expose: DataViewExpose; diff --git a/packages/affine/microsheet-data-view/src/core/view/types.ts b/packages/affine/microsheet-data-view/src/core/view/types.ts index 0d246b69692f..b8429cf7f342 100644 --- a/packages/affine/microsheet-data-view/src/core/view/types.ts +++ b/packages/affine/microsheet-data-view/src/core/view/types.ts @@ -9,17 +9,17 @@ import type { ReadonlySignal } from '@preact/signals-core'; import type { DataSource } from '../common/index.js'; import type { DataViewRenderer } from '../data-view.js'; -import type { DataViewSelection } from '../types.js'; +import type { MicrosheetDataViewSelection } from '../types.js'; import type { SingleView } from '../view-manager/index.js'; -import type { DataViewWidget } from '../widget/index.js'; +import type { MicrosheetDataViewWidget } from '../widget/index.js'; export interface DataViewProps< T extends SingleView = SingleView, - Selection extends DataViewSelection = DataViewSelection, + Selection extends MicrosheetDataViewSelection = MicrosheetDataViewSelection, > { dataViewEle: DataViewRenderer; - headerWidget?: DataViewWidget; + headerWidget?: MicrosheetDataViewWidget; view: T; dataSource: DataSource; @@ -42,7 +42,7 @@ export interface DataViewProps< export interface DataViewExpose { addRow?(position: InsertToPosition | number): void; - getSelection?(): DataViewSelection | undefined; + getSelection?(): MicrosheetDataViewSelection | undefined; focusFirstCell(): void; diff --git a/packages/affine/microsheet-data-view/src/core/widget/types.ts b/packages/affine/microsheet-data-view/src/core/widget/types.ts index 1250bc0f116a..dfadc3ca6d0a 100644 --- a/packages/affine/microsheet-data-view/src/core/widget/types.ts +++ b/packages/affine/microsheet-data-view/src/core/widget/types.ts @@ -2,8 +2,9 @@ import type { UniComponent } from '../utils/uni-component/index.js'; import type { DataViewExpose } from '../view/types.js'; import type { SingleView } from '../view-manager/single-view.js'; -export type DataViewWidgetProps = { +export type MicrosheetDataViewWidgetProps = { view: SingleView; viewMethods: DataViewExpose; }; -export type DataViewWidget = UniComponent; +export type MicrosheetDataViewWidget = + UniComponent; diff --git a/packages/affine/microsheet-data-view/src/core/widget/widget-base.ts b/packages/affine/microsheet-data-view/src/core/widget/widget-base.ts index 750a2afbd907..6eb1925a9024 100644 --- a/packages/affine/microsheet-data-view/src/core/widget/widget-base.ts +++ b/packages/affine/microsheet-data-view/src/core/widget/widget-base.ts @@ -4,11 +4,11 @@ import { property } from 'lit/decorators.js'; import type { DataViewExpose } from '../view/types.js'; import type { SingleView } from '../view-manager/single-view.js'; -import type { DataViewWidgetProps } from './types.js'; +import type { MicrosheetDataViewWidgetProps } from './types.js'; export class WidgetBase extends SignalWatcher(WithDisposable(ShadowlessElement)) - implements DataViewWidgetProps + implements MicrosheetDataViewWidgetProps { get dataSource() { return this.view.manager.dataSource; diff --git a/packages/affine/microsheet-data-view/src/effects.ts b/packages/affine/microsheet-data-view/src/effects.ts index f4c6b433d6e4..9be84ff18b4d 100644 --- a/packages/affine/microsheet-data-view/src/effects.ts +++ b/packages/affine/microsheet-data-view/src/effects.ts @@ -1,55 +1,15 @@ import { Overflow } from './core/common/component/overflow/overflow.js'; import { RecordDetail } from './core/common/detail/detail.js'; import { RecordField } from './core/common/detail/field.js'; -import { BooleanGroupView } from './core/common/group-by/renderer/boolean-group.js'; -import { NumberGroupView } from './core/common/group-by/renderer/number-group.js'; -import { SelectGroupView } from './core/common/group-by/renderer/select-group.js'; -import { StringGroupView } from './core/common/group-by/renderer/string-group.js'; -import { GroupSetting } from './core/common/group-by/setting.js'; -import { DateLiteral } from './core/common/literal/renderer/date-literal.js'; import { - BooleanLiteral, NumberLiteral, StringLiteral, } from './core/common/literal/renderer/literal-element.js'; -import { - MultiTagLiteral, - TagLiteral, -} from './core/common/literal/renderer/tag-literal.js'; -import { TagLiteral as UnionTagLiteral } from './core/common/literal/renderer/union-string.js'; import { DataViewPropertiesSettingView } from './core/common/properties.js'; import { VariableRefView } from './core/common/ref/ref.js'; import { DataViewRenderer } from './core/data-view.js'; -import { - AffineLitIcon, - MultiTagSelect, - MultiTagView, - UniAnyRender, - UniLit, -} from './core/index.js'; +import { AffineLitIcon, UniAnyRender, UniLit } from './core/index.js'; import { AnyRender } from './core/utils/uni-component/render-template.js'; -import { CheckboxCell } from './property-presets/checkbox/cell-renderer.js'; -import { - DateCell, - DateCellEditing, -} from './property-presets/date/cell-renderer.js'; -import { TextCell as ImageTextCell } from './property-presets/image/cell-renderer.js'; -import { - MultiSelectCell, - MultiSelectCellEditing, -} from './property-presets/multi-select/cell-renderer.js'; -import { - NumberCell, - NumberCellEditing, -} from './property-presets/number/cell-renderer.js'; -import { - ProgressCell, - ProgressCellEditing, -} from './property-presets/progress/cell-renderer.js'; -import { - SelectCell, - SelectCellEditing, -} from './property-presets/select/cell-renderer.js'; import { TextCell, TextCellEditing, @@ -62,39 +22,16 @@ import { TableGroup } from './view-presets/table/group.js'; import { MicrosheetColumnHeader } from './view-presets/table/header/column-header.js'; import { DataViewColumnPreview } from './view-presets/table/header/column-renderer.js'; import { MicrosheetHeaderColumn } from './view-presets/table/header/microsheet-header-column.js'; -import { MicrosheetNumberFormatBar } from './view-presets/table/header/number-format-bar.js'; import { TableVerticalIndicator } from './view-presets/table/header/vertical-indicator.js'; import { TableRow } from './view-presets/table/row/row.js'; import { RowSelectCheckbox } from './view-presets/table/row/row-select-checkbox.js'; -import { MicrosheetColumnStats } from './view-presets/table/stats/column-stats-bar.js'; -import { MicrosheetColumnStatsCell } from './view-presets/table/stats/column-stats-column.js'; -import { FilterConditionView } from './widget-presets/filter/condition.js'; -import { FilterBar } from './widget-presets/filter/filter-bar.js'; -import { FilterGroupView } from './widget-presets/filter/filter-group.js'; -import { FilterRootView } from './widget-presets/filter/filter-root.js'; -import { DataViewHeaderToolsFilter } from './widget-presets/tools/presets/filter/filter.js'; -import { DataViewHeaderToolsSearch } from './widget-presets/tools/presets/search/search.js'; -import { DataViewHeaderToolsAddRow } from './widget-presets/tools/presets/table-add-row/add-row.js'; -import { NewRecordPreview } from './widget-presets/tools/presets/table-add-row/new-record-preview.js'; -import { DataViewHeaderToolsViewOptions } from './widget-presets/tools/presets/view-options/view-options.js'; import { DataViewHeaderTools } from './widget-presets/tools/tools-renderer.js'; -import { DataViewHeaderViews } from './widget-presets/views-bar/views.js'; export function effects() { - customElements.define('affine-microsheet-progress-cell', ProgressCell); - customElements.define( - 'affine-microsheet-progress-cell-editing', - ProgressCellEditing - ); customElements.define( 'microsheet-data-view-header-tools', DataViewHeaderTools ); - customElements.define('affine-microsheet-number-cell', NumberCell); - customElements.define( - 'affine-microsheet-number-cell-editing', - NumberCellEditing - ); customElements.define( 'affine-microsheet-cell-container', MicrosheetCellContainer @@ -104,26 +41,12 @@ export function effects() { DataViewRenderer ); customElements.define('microsheet-any-render', AnyRender); - customElements.define('affine-microsheet-image-cell', ImageTextCell); - customElements.define('affine-microsheet-date-cell', DateCell); - customElements.define('affine-microsheet-date-cell-editing', DateCellEditing); customElements.define( 'microsheet-data-view-properties-setting', DataViewPropertiesSettingView ); - customElements.define('affine-microsheet-checkbox-cell', CheckboxCell); customElements.define('affine-microsheet-text-cell', TextCell); customElements.define('affine-microsheet-text-cell-editing', TextCellEditing); - customElements.define('affine-microsheet-select-cell', SelectCell); - customElements.define( - 'affine-microsheet-select-cell-editing', - SelectCellEditing - ); - customElements.define('affine-microsheet-multi-select-cell', MultiSelectCell); - customElements.define( - 'affine-microsheet-multi-select-cell-editing', - MultiSelectCellEditing - ); customElements.define( 'affine-microsheet-data-view-record-field', RecordField @@ -135,28 +58,7 @@ export function effects() { DataViewColumnPreview ); customElements.define('microsheet-component-overflow', Overflow); - customElements.define( - 'microsheet-data-view-group-title-select-view', - SelectGroupView - ); - customElements.define( - 'microsheet-data-view-group-title-string-view', - StringGroupView - ); - customElements.define('microsheet-filter-bar', FilterBar); - customElements.define( - 'microsheet-data-view-group-title-number-view', - NumberGroupView - ); customElements.define('affine-microsheet-lit-icon', AffineLitIcon); - customElements.define( - 'microsheet-filter-condition-view', - FilterConditionView - ); - customElements.define( - 'microsheet-data-view-literal-boolean-view', - BooleanLiteral - ); customElements.define( 'microsheet-data-view-literal-number-view', NumberLiteral @@ -165,69 +67,22 @@ export function effects() { 'microsheet-data-view-literal-string-view', StringLiteral ); - customElements.define('microsheet-data-view-group-setting', GroupSetting); - customElements.define('microsheet-data-view-literal-tag-view', TagLiteral); - customElements.define( - 'microsheet-data-view-literal-multi-tag-view', - MultiTagLiteral - ); - customElements.define( - 'microsheet-data-view-literal-union-string-view', - UnionTagLiteral - ); - customElements.define('affine-microsheet-multi-tag-select', MultiTagSelect); - customElements.define( - 'microsheet-data-view-group-title-boolean-view', - BooleanGroupView - ); - customElements.define('microsheet-data-view-literal-date-view', DateLiteral); customElements.define('affine-microsheet-table', DataViewTable); - customElements.define('affine-microsheet-multi-tag-view', MultiTagView); - customElements.define( - 'microsheet-data-view-header-tools-search', - DataViewHeaderToolsSearch - ); customElements.define('microsheet-uni-lit', UniLit); customElements.define('microsheet-uni-any-render', UniAnyRender); - customElements.define('microsheet-filter-group-view', FilterGroupView); - customElements.define( - 'microsheet-data-view-header-tools-add-row', - DataViewHeaderToolsAddRow - ); customElements.define( 'microsheet-data-view-table-selection', SelectionElement ); - customElements.define( - 'affine-microsheet-new-record-preview', - NewRecordPreview - ); - customElements.define( - 'microsheet-data-view-header-tools-filter', - DataViewHeaderToolsFilter - ); - customElements.define( - 'microsheet-data-view-header-tools-view-options', - DataViewHeaderToolsViewOptions - ); customElements.define('microsheet-variable-ref-view', VariableRefView); customElements.define( 'affine-microsheet-data-view-record-detail', RecordDetail ); - customElements.define('microsheet-filter-root-view', FilterRootView); customElements.define( 'affine-microsheet-column-header', MicrosheetColumnHeader ); - customElements.define( - 'microsheet-data-view-header-views', - DataViewHeaderViews - ); - customElements.define( - 'affine-microsheet-number-format-bar', - MicrosheetNumberFormatBar - ); customElements.define( 'affine-microsheet-header-column', MicrosheetHeaderColumn @@ -238,12 +93,4 @@ export function effects() { TableVerticalIndicator ); customElements.define('microsheet-data-view-table-row', TableRow); - customElements.define( - 'affine-microsheet-column-stats', - MicrosheetColumnStats - ); - customElements.define( - 'affine-microsheet-column-stats-cell', - MicrosheetColumnStatsCell - ); } diff --git a/packages/affine/microsheet-data-view/src/property-presets/checkbox/cell-renderer.ts b/packages/affine/microsheet-data-view/src/property-presets/checkbox/cell-renderer.ts deleted file mode 100644 index 122b787b311a..000000000000 --- a/packages/affine/microsheet-data-view/src/property-presets/checkbox/cell-renderer.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { CheckBoxCkeckSolidIcon, CheckBoxUnIcon } from '@blocksuite/icons/lit'; -import { css, html } from 'lit'; -import { query } from 'lit/decorators.js'; - -import { BaseCellRenderer } from '../../core/property/index.js'; -import { createFromBaseCellRenderer } from '../../core/property/renderer.js'; -import { createIcon } from '../../core/utils/uni-icon.js'; -import { checkboxPropertyModelConfig } from './define.js'; - -const playCheckAnimation = async ( - refElement: Element, - { left = 0, size = 20 }: { left?: number; size?: number } = {} -) => { - const sparkingEl = document.createElement('div'); - sparkingEl.classList.add('affine-check-animation'); - if (size < 20) { - console.warn('If the size is less than 20, the animation may be abnormal.'); - } - sparkingEl.style.cssText = ` - position: absolute; - width: ${size}px; - height: ${size}px; - border-radius: 50%; - `; - sparkingEl.style.left = `${left}px`; - refElement.append(sparkingEl); - - await sparkingEl.animate( - [ - { - boxShadow: - '0 -18px 0 -8px #1e96eb, 16px -8px 0 -8px #1e96eb, 16px 8px 0 -8px #1e96eb, 0 18px 0 -8px #1e96eb, -16px 8px 0 -8px #1e96eb, -16px -8px 0 -8px #1e96eb', - }, - ], - { duration: 240, easing: 'ease', fill: 'forwards' } - ).finished; - await sparkingEl.animate( - [ - { - boxShadow: - '0 -36px 0 -10px transparent, 32px -16px 0 -10px transparent, 32px 16px 0 -10px transparent, 0 36px 0 -10px transparent, -32px 16px 0 -10px transparent, -32px -16px 0 -10px transparent', - }, - ], - { duration: 360, easing: 'ease', fill: 'forwards' } - ).finished; - - sparkingEl.remove(); -}; - -export class CheckboxCell extends BaseCellRenderer { - static override styles = css` - affine-microsheet-checkbox-cell { - display: block; - width: 100%; - cursor: pointer; - } - - .affine-microsheet-checkbox-container { - height: 100%; - } - - .affine-microsheet-checkbox { - display: flex; - align-items: center; - height: var(--data-view-cell-text-line-height); - width: 100%; - position: relative; - } - .affine-microsheet-checkbox svg { - width: 16px; - height: 16px; - } - `; - - override beforeEnterEditMode() { - const checked = !this.value; - - this.onChange(checked); - if (checked) { - playCheckAnimation(this._checkbox, { left: -2 }).catch(console.error); - } - return false; - } - - override onCopy(_e: ClipboardEvent) { - _e.preventDefault(); - } - - override onCut(_e: ClipboardEvent) { - _e.preventDefault(); - } - - override onPaste(_e: ClipboardEvent) { - _e.preventDefault(); - } - - override render() { - const checked = this.value ?? false; - const icon = checked - ? CheckBoxCkeckSolidIcon({ style: `color:#1E96EB` }) - : CheckBoxUnIcon(); - return html`
-
- ${icon} -
-
`; - } - - @query('.affine-microsheet-checkbox') - private accessor _checkbox!: HTMLDivElement; -} - -export const checkboxPropertyConfig = - checkboxPropertyModelConfig.createPropertyMeta({ - icon: createIcon('CheckBoxCheckLinearIcon'), - cellRenderer: { - view: createFromBaseCellRenderer(CheckboxCell), - }, - }); diff --git a/packages/affine/microsheet-data-view/src/property-presets/checkbox/define.ts b/packages/affine/microsheet-data-view/src/property-presets/checkbox/define.ts deleted file mode 100644 index cfc25f4e15df..000000000000 --- a/packages/affine/microsheet-data-view/src/property-presets/checkbox/define.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { tBoolean } from '../../core/logical/data-type.js'; -import { propertyType } from '../../core/property/property-config.js'; - -export const checkboxPropertyType = propertyType('checkbox'); - -export const checkboxPropertyModelConfig = - checkboxPropertyType.modelConfig({ - name: 'Checkbox', - type: () => tBoolean.create(), - defaultData: () => ({}), - cellToString: data => (data ? 'True' : 'False'), - cellFromString: data => { - return { - value: data !== 'False', - }; - }, - cellToJson: data => data ?? null, - isEmpty: () => false, - }); diff --git a/packages/affine/microsheet-data-view/src/property-presets/converts.ts b/packages/affine/microsheet-data-view/src/property-presets/converts.ts deleted file mode 100644 index ca3c059ffdeb..000000000000 --- a/packages/affine/microsheet-data-view/src/property-presets/converts.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { clamp } from '@blocksuite/affine-shared/utils'; - -import { createPropertyConvert } from '../core/index.js'; -import { multiSelectPropertyModelConfig } from './multi-select/define.js'; -import { numberPropertyModelConfig } from './number/define.js'; -import { progressPropertyModelConfig } from './progress/define.js'; -import { selectPropertyModelConfig } from './select/define.js'; - -export const presetPropertyConverts = [ - createPropertyConvert( - multiSelectPropertyModelConfig, - selectPropertyModelConfig, - (property, cells) => ({ - property, - cells: cells.map(v => v?.[0]), - }) - ), - createPropertyConvert( - numberPropertyModelConfig, - progressPropertyModelConfig, - (_property, cells) => ({ - property: {}, - cells: cells.map(v => clamp(v ?? 0, 0, 100)), - }) - ), - createPropertyConvert( - progressPropertyModelConfig, - numberPropertyModelConfig, - (_property, cells) => ({ - property: { - decimal: 0, - format: 'number' as const, - }, - cells: cells.map(v => v), - }) - ), - createPropertyConvert( - selectPropertyModelConfig, - multiSelectPropertyModelConfig, - (property, cells) => ({ - property, - cells: cells.map(v => (v ? [v] : undefined)), - }) - ), -]; diff --git a/packages/affine/microsheet-data-view/src/property-presets/date/cell-renderer.ts b/packages/affine/microsheet-data-view/src/property-presets/date/cell-renderer.ts deleted file mode 100644 index 75132059c753..000000000000 --- a/packages/affine/microsheet-data-view/src/property-presets/date/cell-renderer.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { DatePicker } from '@blocksuite/affine-components/date-picker'; -import { createLitPortal } from '@blocksuite/affine-components/portal'; -import { flip, offset } from '@floating-ui/dom'; -import { baseTheme } from '@toeverything/theme'; -import { format } from 'date-fns/format'; -import { css, html, unsafeCSS } from 'lit'; -import { state } from 'lit/decorators.js'; - -import { BaseCellRenderer } from '../../core/property/index.js'; -import { createFromBaseCellRenderer } from '../../core/property/renderer.js'; -import { createIcon } from '../../core/utils/uni-icon.js'; -import { datePropertyModelConfig } from './define.js'; - -export class DateCell extends BaseCellRenderer { - static override styles = css` - affine-microsheet-date-cell { - width: 100%; - } - - .affine-microsheet-date { - 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); - height: var(--data-view-cell-text-line-height); - } - - input.affine-microsheet-date[type='date']::-webkit-calendar-picker-indicator { - display: none; - } - `; - - override render() { - const value = this.value ? format(this.value, 'yyyy/MM/dd') : ''; - if (!value) { - return ''; - } - return html`
${value}
`; - } -} - -export class DateCellEditing extends BaseCellRenderer { - static override styles = css` - affine-microsheet-date-cell-editing { - width: 100%; - cursor: text; - } - - .affine-microsheet-date:focus { - outline: none; - } - `; - - private _prevPortalAbortController: AbortController | null = null; - - private openDatePicker = () => { - if ( - this._prevPortalAbortController && - !this._prevPortalAbortController.signal.aborted - ) - return; - - this._prevPortalAbortController?.abort(); - const abortController = new AbortController(); - abortController.signal.addEventListener( - 'abort', - () => { - this.selectCurrentCell(false); - }, - { once: true } - ); - this._prevPortalAbortController = abortController; - const root = createLitPortal({ - abortController, - closeOnClickAway: true, - computePosition: { - referenceElement: this, - placement: 'bottom', - middleware: [offset(10), flip()], - }, - template: () => { - const datePicker = new DatePicker(); - datePicker.value = this.value ?? Date.now(); - datePicker.onChange = (date: Date) => { - this.tempValue = date; - }; - datePicker.onEscape = () => { - abortController.abort(); - }; - requestAnimationFrame(() => datePicker.focusDateCell()); - return datePicker; - }, - }); - // TODO: use z-index from variable, - // for now the slide-layout-modal's z-index is `1001` - // the z-index of popover should be higher than it - // root.style.zIndex = 'var(--affine-z-index-popover)'; - root.style.zIndex = '1002'; - }; - - private updateValue = () => { - const tempValue = this.tempValue; - if (!tempValue) { - return; - } - - this.onChange(tempValue.getTime()); - this.tempValue = undefined; - }; - - get dateString() { - const value = this.tempValue ?? this.value; - return value ? format(value, 'yyyy/MM/dd') : ''; - } - - override firstUpdated() { - this.openDatePicker(); - } - - override onExitEditMode() { - this.updateValue(); - this._prevPortalAbortController?.abort(); - } - - override render() { - return html`
- ${this.dateString} -
`; - } - - @state() - accessor tempValue: Date | undefined = undefined; -} - -export const datePropertyConfig = datePropertyModelConfig.createPropertyMeta({ - icon: createIcon('DateTimeIcon'), - cellRenderer: { - view: createFromBaseCellRenderer(DateCell), - edit: createFromBaseCellRenderer(DateCellEditing), - }, -}); diff --git a/packages/affine/microsheet-data-view/src/property-presets/date/define.ts b/packages/affine/microsheet-data-view/src/property-presets/date/define.ts deleted file mode 100644 index 6cebdd548ad5..000000000000 --- a/packages/affine/microsheet-data-view/src/property-presets/date/define.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { tDate } from '../../core/logical/data-type.js'; -import { propertyType } from '../../core/property/property-config.js'; - -export const datePropertyType = propertyType('date'); -export const datePropertyModelConfig = datePropertyType.modelConfig({ - name: 'Date', - type: () => tDate.create(), - defaultData: () => ({}), - cellToString: data => data?.toString() ?? '', - cellFromString: data => { - const isDateFormat = !isNaN(Date.parse(data)); - - return { - value: isDateFormat ? +new Date(data) : null, - }; - }, - cellToJson: data => data ?? null, - isEmpty: data => data == null, -}); diff --git a/packages/affine/microsheet-data-view/src/property-presets/image/cell-renderer.ts b/packages/affine/microsheet-data-view/src/property-presets/image/cell-renderer.ts deleted file mode 100644 index 6466a5f80ed6..000000000000 --- a/packages/affine/microsheet-data-view/src/property-presets/image/cell-renderer.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { css, html } from 'lit'; - -import { BaseCellRenderer } from '../../core/property/index.js'; -import { createFromBaseCellRenderer } from '../../core/property/renderer.js'; -import { createIcon } from '../../core/utils/uni-icon.js'; -import { imagePropertyModelConfig } from './define.js'; - -export class TextCell extends BaseCellRenderer { - static override styles = css` - affine-microsheet-image-cell { - width: 100%; - height: 100%; - display: flex; - align-items: center; - } - affine-microsheet-image-cell img { - width: 20px; - height: 20px; - } - `; - - override render() { - return html``; - } -} - -export const imagePropertyConfig = imagePropertyModelConfig.createPropertyMeta({ - icon: createIcon('ImageIcon'), - cellRenderer: { - view: createFromBaseCellRenderer(TextCell), - }, -}); diff --git a/packages/affine/microsheet-data-view/src/property-presets/image/define.ts b/packages/affine/microsheet-data-view/src/property-presets/image/define.ts deleted file mode 100644 index 2fb1600871c8..000000000000 --- a/packages/affine/microsheet-data-view/src/property-presets/image/define.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { tImage } from '../../core/logical/data-type.js'; -import { propertyType } from '../../core/property/property-config.js'; - -export const imagePropertyType = propertyType('image'); - -export const imagePropertyModelConfig = imagePropertyType.modelConfig({ - name: 'image', - type: () => tImage.create(), - defaultData: () => ({}), - cellToString: data => data ?? '', - cellFromString: data => { - return { - value: data, - }; - }, - cellToJson: data => data ?? null, - isEmpty: data => data == null, -}); diff --git a/packages/affine/microsheet-data-view/src/property-presets/index.ts b/packages/affine/microsheet-data-view/src/property-presets/index.ts index b1b8b3d22748..f109b1d7207c 100644 --- a/packages/affine/microsheet-data-view/src/property-presets/index.ts +++ b/packages/affine/microsheet-data-view/src/property-presets/index.ts @@ -1,22 +1,5 @@ -import { checkboxPropertyConfig } from './checkbox/cell-renderer.js'; -import { datePropertyConfig } from './date/cell-renderer.js'; -import { imagePropertyConfig } from './image/cell-renderer.js'; -import { multiSelectPropertyConfig } from './multi-select/cell-renderer.js'; -import { numberPropertyConfig } from './number/cell-renderer.js'; -import { progressPropertyConfig } from './progress/cell-renderer.js'; -import { selectPropertyConfig } from './select/cell-renderer.js'; import { textPropertyConfig } from './text/cell-renderer.js'; -export * from './converts.js'; -export * from './number/types.js'; -export * from './select/define.js'; export const propertyPresets = { - checkboxPropertyConfig, - datePropertyConfig, - imagePropertyConfig, - multiSelectPropertyConfig, - numberPropertyConfig, - progressPropertyConfig, - selectPropertyConfig, textPropertyConfig, }; diff --git a/packages/affine/microsheet-data-view/src/property-presets/multi-select/cell-renderer.ts b/packages/affine/microsheet-data-view/src/property-presets/multi-select/cell-renderer.ts deleted file mode 100644 index 6e66a229e9c8..000000000000 --- a/packages/affine/microsheet-data-view/src/property-presets/multi-select/cell-renderer.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { popupTargetFromElement } from '@blocksuite/affine-components/context-menu'; -import { html } from 'lit/static-html.js'; - -import type { SelectPropertyData } from '../select/define.js'; - -import { BaseCellRenderer } from '../../core/property/index.js'; -import { createFromBaseCellRenderer } from '../../core/property/renderer.js'; -import { - popTagSelect, - type SelectTag, -} from '../../core/utils/tags/multi-tag-select.js'; -import { createIcon } from '../../core/utils/uni-icon.js'; -import { multiSelectPropertyModelConfig } from './define.js'; - -export class MultiSelectCell extends BaseCellRenderer< - string[], - SelectPropertyData -> { - override render() { - return html` - - `; - } -} - -export class MultiSelectCellEditing extends BaseCellRenderer< - string[], - SelectPropertyData -> { - private popTagSelect = () => { - this._disposables.add({ - dispose: popTagSelect( - popupTargetFromElement( - this.querySelector('affine-microsheet-multi-tag-view') ?? this - ), - { - options: this._options, - onOptionsChange: this._onOptionsChange, - value: this._value, - onChange: this._onChange, - onComplete: this._editComplete, - minWidth: 400, - } - ), - }); - }; - - _editComplete = () => { - this.selectCurrentCell(false); - }; - - _onChange = (ids: string[]) => { - this.onChange(ids); - }; - - _onOptionsChange = (options: SelectTag[]) => { - this.property.dataUpdate(data => { - return { - ...data, - options, - }; - }); - }; - - get _options(): SelectTag[] { - return this.property.data$.value.options; - } - - get _value() { - return this.value ?? []; - } - - override firstUpdated() { - this.popTagSelect(); - } - - override render() { - return html` - - `; - } -} - -export const multiSelectPropertyConfig = - multiSelectPropertyModelConfig.createPropertyMeta({ - icon: createIcon('MultiSelectIcon'), - cellRenderer: { - view: createFromBaseCellRenderer(MultiSelectCell), - edit: createFromBaseCellRenderer(MultiSelectCellEditing), - }, - }); diff --git a/packages/affine/microsheet-data-view/src/property-presets/multi-select/define.ts b/packages/affine/microsheet-data-view/src/property-presets/multi-select/define.ts deleted file mode 100644 index a789dd64b3f0..000000000000 --- a/packages/affine/microsheet-data-view/src/property-presets/multi-select/define.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { nanoid } from '@blocksuite/store'; - -import type { SelectTag } from '../../core/utils/tags/multi-tag-select.js'; -import type { SelectPropertyData } from '../select/define.js'; - -import { tTag } from '../../core/logical/data-type.js'; -import { tArray } from '../../core/logical/typesystem.js'; -import { propertyType } from '../../core/property/property-config.js'; -import { getTagColor } from '../../core/utils/tags/colors.js'; - -export const multiSelectPropertyType = propertyType('multi-select'); -export const multiSelectPropertyModelConfig = - multiSelectPropertyType.modelConfig({ - name: 'Multi-select', - type: data => tArray(tTag.create({ tags: data.options })), - defaultData: () => ({ - options: [], - }), - addGroup: (text, oldData) => { - return { - options: [ - ...(oldData.options ?? []), - { - id: nanoid(), - value: text, - color: getTagColor(), - }, - ], - }; - }, - formatValue: v => { - if (Array.isArray(v)) { - return v.filter(v => v != null); - } - return []; - }, - cellToString: (data, colData) => - data?.map(id => colData.options.find(v => v.id === id)?.value).join(','), - cellFromString: (data, colData) => { - const optionMap = Object.fromEntries( - colData.options.map(v => [v.value, v]) - ); - const optionNames = data - .split(',') - .map(v => v.trim()) - .filter(v => v); - - const value: string[] = []; - optionNames.forEach(name => { - if (!optionMap[name]) { - const newOption: SelectTag = { - id: nanoid(), - value: name, - color: getTagColor(), - }; - colData.options.push(newOption); - value.push(newOption.id); - } else { - value.push(optionMap[name].id); - } - }); - - return { - value, - data: colData, - }; - }, - cellToJson: data => data ?? null, - isEmpty: data => data == null || data.length === 0, - }); diff --git a/packages/affine/microsheet-data-view/src/property-presets/number/cell-renderer.ts b/packages/affine/microsheet-data-view/src/property-presets/number/cell-renderer.ts deleted file mode 100644 index ca3d9b7b471a..000000000000 --- a/packages/affine/microsheet-data-view/src/property-presets/number/cell-renderer.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { IS_MAC } from '@blocksuite/global/env'; -import { baseTheme } from '@toeverything/theme'; -import { css, html, unsafeCSS } from 'lit'; -import { query } from 'lit/decorators.js'; - -import type { NumberPropertyDataType } from './types.js'; - -import { BaseCellRenderer } from '../../core/property/index.js'; -import { createFromBaseCellRenderer } from '../../core/property/renderer.js'; -import { stopPropagation } from '../../core/utils/event.js'; -import { createIcon } from '../../core/utils/uni-icon.js'; -import { numberPropertyModelConfig } from './define.js'; -import { - formatNumber, - type NumberFormat, - parseNumber, -} from './utils/formatter.js'; - -export class NumberCell extends BaseCellRenderer< - number, - NumberPropertyDataType -> { - static override styles = css` - affine-microsheet-number-cell { - display: block; - width: 100%; - } - - .affine-microsheet-number { - display: flex; - align-items: center; - justify-content: flex-end; - width: 100%; - padding: 0; - border: none; - font-family: ${unsafeCSS(baseTheme.fontSansFamily)}; - font-size: var(--data-view-cell-text-size); - line-height: var(--data-view-cell-text-line-height); - color: var(--affine-text-primary-color); - font-weight: 400; - background-color: transparent; - } - `; - - private _getFormattedString() { - const enableNewFormatting = - this.view.featureFlags$.value.enable_number_formatting; - const decimals = this.property.data$.value.decimal ?? 0; - const formatMode = (this.property.data$.value.format ?? - 'number') as NumberFormat; - return this.value - ? enableNewFormatting - ? formatNumber(this.value, formatMode, decimals) - : this.value.toString() - : ''; - } - - override render() { - return html`
- ${this._getFormattedString()} -
`; - } -} - -export class NumberCellEditing extends BaseCellRenderer< - number, - NumberPropertyDataType -> { - static override styles = css` - affine-microsheet-number-cell-editing { - display: block; - width: 100%; - cursor: text; - } - - .affine-microsheet-number { - display: flex; - align-items: center; - width: 100%; - padding: 0; - border: none; - font-family: ${unsafeCSS(baseTheme.fontSansFamily)}; - font-size: var(--data-view-cell-text-size); - line-height: var(--data-view-cell-text-line-height); - color: var(--affine-text-primary-color); - font-weight: 400; - background-color: transparent; - text-align: right; - } - - .affine-microsheet-number:focus { - outline: none; - } - `; - - private _getFormattedString = (value: number) => { - const enableNewFormatting = - this.view.featureFlags$.value.enable_number_formatting; - const decimals = this.property.data$.value.decimal ?? 0; - const formatMode = (this.property.data$.value.format ?? - 'number') as NumberFormat; - return enableNewFormatting - ? formatNumber(value, formatMode, decimals) - : value.toString(); - }; - - private _keydown = (e: KeyboardEvent) => { - const ctrlKey = IS_MAC ? e.metaKey : e.ctrlKey; - - if (e.key.toLowerCase() === 'z' && ctrlKey) { - e.stopPropagation(); - return; - } - - if (e.key === 'Enter' && !e.isComposing) { - requestAnimationFrame(() => { - this.selectCurrentCell(false); - }); - } - }; - - private _setValue = (str: string = this._inputEle.value) => { - if (!str) { - this.onChange(undefined); - return; - } - - const enableNewFormatting = - this.view.featureFlags$.value.enable_number_formatting; - const value = enableNewFormatting ? parseNumber(str) : parseFloat(str); - if (isNaN(value)) { - this._inputEle.value = this.value - ? this._getFormattedString(this.value) - : ''; - return; - } - - this._inputEle.value = this._getFormattedString(value); - this.onChange(value); - }; - - focusEnd = () => { - const end = this._inputEle.value.length; - this._inputEle.focus(); - this._inputEle.setSelectionRange(end, end); - }; - - _blur() { - this.selectCurrentCell(false); - } - - _focus() { - if (!this.isEditing) { - this.selectCurrentCell(true); - } - } - - override firstUpdated() { - requestAnimationFrame(() => { - this.focusEnd(); - }); - } - - override onExitEditMode() { - this._setValue(); - } - - override render() { - const formatted = this.value ? this._getFormattedString(this.value) : ''; - - return html``; - } - - @query('input') - private accessor _inputEle!: HTMLInputElement; -} - -export const numberPropertyConfig = - numberPropertyModelConfig.createPropertyMeta({ - icon: createIcon('NumberIcon'), - cellRenderer: { - view: createFromBaseCellRenderer(NumberCell), - edit: createFromBaseCellRenderer(NumberCellEditing), - }, - }); diff --git a/packages/affine/microsheet-data-view/src/property-presets/number/define.ts b/packages/affine/microsheet-data-view/src/property-presets/number/define.ts deleted file mode 100644 index fe8d1bcf4275..000000000000 --- a/packages/affine/microsheet-data-view/src/property-presets/number/define.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { NumberPropertyDataType } from './types.js'; - -import { tNumber } from '../../core/logical/data-type.js'; -import { propertyType } from '../../core/property/property-config.js'; - -export const numberPropertyType = propertyType('number'); - -export const numberPropertyModelConfig = numberPropertyType.modelConfig< - number, - NumberPropertyDataType ->({ - name: 'Number', - type: () => tNumber.create(), - defaultData: () => ({ decimal: 0, format: 'number' }), - cellToString: data => data?.toString() ?? '', - cellFromString: data => { - const num = data ? Number(data) : NaN; - return { - value: isNaN(num) ? null : num, - }; - }, - cellToJson: data => data ?? null, - isEmpty: data => data == null, -}); diff --git a/packages/affine/microsheet-data-view/src/property-presets/number/index.ts b/packages/affine/microsheet-data-view/src/property-presets/number/index.ts deleted file mode 100644 index d4702960d547..000000000000 --- a/packages/affine/microsheet-data-view/src/property-presets/number/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './types.js'; diff --git a/packages/affine/microsheet-data-view/src/property-presets/number/types.ts b/packages/affine/microsheet-data-view/src/property-presets/number/types.ts deleted file mode 100644 index f0f0f790a7ae..000000000000 --- a/packages/affine/microsheet-data-view/src/property-presets/number/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { NumberFormat } from './utils/formatter.js'; - -export type NumberPropertyDataType = { - decimal?: number; - format?: NumberFormat; -}; diff --git a/packages/affine/microsheet-data-view/src/property-presets/number/utils/formats.ts b/packages/affine/microsheet-data-view/src/property-presets/number/utils/formats.ts deleted file mode 100644 index e1437571484d..000000000000 --- a/packages/affine/microsheet-data-view/src/property-presets/number/utils/formats.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { NumberFormat } from './formatter.js'; - -export type NumberCellFormat = { - type: NumberFormat; - label: string; - symbol: string; // New property for symbol -}; - -export const numberFormats: NumberCellFormat[] = [ - { type: 'number', label: 'Number', symbol: '#' }, - { type: 'numberWithCommas', label: 'Number With Commas', symbol: '#' }, - { type: 'percent', label: 'Percent', symbol: '%' }, - { type: 'currencyYen', label: 'Japanese Yen', symbol: '¥' }, - { type: 'currencyCNY', label: 'Chinese Yuan', symbol: '¥' }, - { type: 'currencyINR', label: 'Indian Rupee', symbol: '₹' }, - { type: 'currencyUSD', label: 'US Dollar', symbol: '$' }, - { type: 'currencyEUR', label: 'Euro', symbol: '€' }, - { type: 'currencyGBP', label: 'British Pound', symbol: '£' }, -]; diff --git a/packages/affine/microsheet-data-view/src/property-presets/number/utils/formatter.ts b/packages/affine/microsheet-data-view/src/property-presets/number/utils/formatter.ts deleted file mode 100644 index 4b418d5eea2f..000000000000 --- a/packages/affine/microsheet-data-view/src/property-presets/number/utils/formatter.ts +++ /dev/null @@ -1,101 +0,0 @@ -export type NumberFormat = - | 'number' - | 'numberWithCommas' - | 'percent' - | 'currencyYen' - | 'currencyINR' - | 'currencyCNY' - | 'currencyUSD' - | 'currencyEUR' - | 'currencyGBP'; - -const currency = (currency: string): Intl.NumberFormatOptions => ({ - style: 'currency', - currency, - currencyDisplay: 'symbol', -}); - -const numberFormatDefaultConfig: Record< - NumberFormat, - Intl.NumberFormatOptions -> = { - number: { style: 'decimal', useGrouping: false }, - numberWithCommas: { style: 'decimal', useGrouping: true }, - percent: { style: 'percent', useGrouping: false }, - currencyINR: currency('INR'), - currencyYen: currency('JPY'), - currencyCNY: currency('CNY'), - currencyUSD: currency('USD'), - currencyEUR: currency('EUR'), - currencyGBP: currency('GBP'), -}; - -export function formatNumber( - value: number, - format: NumberFormat, - decimals?: number -) { - const formatterOptions = { ...numberFormatDefaultConfig[format] }; - if (decimals !== undefined) { - // for feature flag should default to 0 after release - Object.assign(formatterOptions, { - minimumFractionDigits: decimals, - maximumFractionDigits: decimals, - }); - } - const formatter = new Intl.NumberFormat(navigator.language, formatterOptions); - return formatter.format(value); -} - -export function getLocaleDecimalSeparator(locale?: string) { - return (1.1).toLocaleString(locale ?? navigator.language).slice(1, 2); -} - -// Since we Intl does not provide a parse function we just made it ourself -export function parseNumber(value: string, decimalSeparator?: string): number { - decimalSeparator = decimalSeparator ?? getLocaleDecimalSeparator(); - - // Normalize decimal separator to a period for consistency - const normalizedValue = value.replace( - new RegExp(`\\${decimalSeparator}`, 'g'), - '.' - ); - - // Remove any leading and trailing non-numeric characters except valid signs, decimal points, and exponents - let sanitizedValue = normalizedValue.replace(/^[^\d-+eE.]+|[^\d]+$/g, ''); - - // Remove non-numeric characters except decimal points, exponents, and valid signs - sanitizedValue = sanitizedValue.replace(/[^0-9.eE+-]/g, ''); - - // Handle multiple signs: Keep only the first sign - sanitizedValue = sanitizedValue.replace(/([-+]){2,}/g, '$1'); - - // Handle misplaced signs: Keep only the leading sign and sign after 'e' or 'E' - sanitizedValue = sanitizedValue.replace( - /^([-+]?)[^eE]*([eE][-+]?\d+)?$/, - (_, p1, p2) => - p1 + - sanitizedValue.replace(/[eE].*/, '').replace(/[^\d.]/g, '') + - (p2 || '') - ); - - // Handle multiple decimal points: Keep only the first one in the main part - sanitizedValue = sanitizedValue.replace(/(\..*)\./g, '$1'); - - // If there is an 'e' or 'E', handle the scientific notation - if (/[eE]/.test(sanitizedValue)) { - const [base, exp] = sanitizedValue.split(/[eE]/); - if ( - !base || - !exp || - exp.includes('.') || - exp.includes('e') || - exp.includes('E') - ) { - return NaN; // Invalid scientific notation - } - return parseFloat(sanitizedValue); - } - - return parseFloat(sanitizedValue); -} diff --git a/packages/affine/microsheet-data-view/src/property-presets/progress/cell-renderer.ts b/packages/affine/microsheet-data-view/src/property-presets/progress/cell-renderer.ts deleted file mode 100644 index 2e161150b1b4..000000000000 --- a/packages/affine/microsheet-data-view/src/property-presets/progress/cell-renderer.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { css, html } from 'lit'; -import { query, state } from 'lit/decorators.js'; -import { styleMap } from 'lit/directives/style-map.js'; - -import { BaseCellRenderer } from '../../core/property/index.js'; -import { createFromBaseCellRenderer } from '../../core/property/renderer.js'; -import { startDrag } from '../../core/utils/drag.js'; -import { createIcon } from '../../core/utils/uni-icon.js'; -import { progressPropertyModelConfig } from './define.js'; - -const styles = css` - affine-microsheet-progress-cell-editing { - display: block; - width: 100%; - padding: 0 4px; - } - - affine-microsheet-progress-cell { - display: block; - width: 100%; - padding: 0 4px; - } - - .affine-microsheet-progress { - display: flex; - align-items: center; - height: var(--data-view-cell-text-line-height); - gap: 4px; - } - - .affine-microsheet-progress-bar { - position: relative; - width: 104px; - } - - .affine-microsheet-progress-bg { - overflow: hidden; - width: 100%; - height: 10px; - border-radius: 22px; - } - - .affine-microsheet-progress-fg { - height: 100%; - } - - .affine-microsheet-progress-drag-handle { - position: absolute; - top: 0; - left: 0; - transform: translate(0px, -1px); - width: 6px; - height: 12px; - border-radius: 2px; - opacity: 1; - cursor: ew-resize; - background: var(--affine-primary-color); - transition: opacity 0.2s ease-in-out; - } - - .progress-number { - display: flex; - justify-content: center; - align-items: center; - height: 18px; - width: 25px; - color: var(--affine-text-secondary-color); - font-size: 14px; - } -`; - -const progressColors = { - empty: 'var(--affine-black-10)', - processing: 'var(--affine-processing-color)', - success: 'var(--affine-success-color)', -}; - -export class ProgressCell extends BaseCellRenderer { - static override styles = styles; - - protected override render() { - const progress = this.value ?? 0; - let backgroundColor = progressColors.processing; - if (progress === 100) { - backgroundColor = progressColors.success; - } - const fgStyles = styleMap({ - width: `${progress}%`, - backgroundColor, - }); - const bgStyles = styleMap({ - backgroundColor: - progress === 0 ? progressColors.empty : 'var(--affine-hover-color)', - }); - - return html`
-
-
-
-
-
-
${progress}
-
`; - } -} - -export class ProgressCellEditing extends BaseCellRenderer { - static override styles = styles; - - startDrag = (event: MouseEvent) => { - const bgRect = this._progressBg.getBoundingClientRect(); - const min = bgRect.left; - const max = bgRect.right; - const setValue = (x: number) => { - this.tempValue = Math.round( - ((Math.min(max, Math.max(min, x)) - min) / (max - min)) * 100 - ); - }; - startDrag(event, { - onDrag: ({ x }) => { - setValue(x); - return; - }, - onMove: ({ x }) => { - setValue(x); - return; - }, - onDrop: () => { - // - }, - onClear: () => { - // - }, - }); - }; - - get _value() { - return this.tempValue ?? this.value ?? 0; - } - - _onChange(value?: number) { - this.tempValue = value; - } - - override firstUpdated() { - const disposables = this._disposables; - - disposables.addFromEvent(this._progressBg, 'pointerdown', this.startDrag); - disposables.addFromEvent(window, 'keydown', evt => { - if (evt.key === 'ArrowDown') { - this._onChange(Math.max(0, this._value - 1)); - return; - } - if (evt.key === 'ArrowUp') { - this._onChange(Math.min(100, this._value + 1)); - return; - } - }); - } - - override onCopy(_e: ClipboardEvent) { - _e.preventDefault(); - } - - override onCut(_e: ClipboardEvent) { - _e.preventDefault(); - } - - override onExitEditMode() { - this.onChange(this._value); - } - - override onPaste(_e: ClipboardEvent) { - _e.preventDefault(); - } - - protected override render() { - const progress = this._value; - let backgroundColor = progressColors.processing; - if (progress === 100) { - backgroundColor = progressColors.success; - } - const fgStyles = styleMap({ - width: `${progress}%`, - backgroundColor, - }); - const bgStyles = styleMap({ - backgroundColor: - progress === 0 ? progressColors.empty : 'var(--affine-hover-color)', - }); - const handleStyles = styleMap({ - left: `calc(${progress}% - 3px)`, - }); - - return html`
-
-
-
-
-
-
-
${progress}
-
`; - } - - @query('.affine-microsheet-progress-bg') - private accessor _progressBg!: HTMLElement; - - @state() - private accessor tempValue: number | undefined = undefined; -} - -export const progressPropertyConfig = - progressPropertyModelConfig.createPropertyMeta({ - icon: createIcon('ProgressIcon'), - cellRenderer: { - view: createFromBaseCellRenderer(ProgressCell), - edit: createFromBaseCellRenderer(ProgressCellEditing), - }, - }); diff --git a/packages/affine/microsheet-data-view/src/property-presets/progress/define.ts b/packages/affine/microsheet-data-view/src/property-presets/progress/define.ts deleted file mode 100644 index f7e57e2ede64..000000000000 --- a/packages/affine/microsheet-data-view/src/property-presets/progress/define.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { tNumber } from '../../core/logical/data-type.js'; -import { propertyType } from '../../core/property/property-config.js'; - -export const progressPropertyType = propertyType('progress'); - -export const progressPropertyModelConfig = - progressPropertyType.modelConfig({ - name: 'Progress', - type: () => tNumber.create(), - defaultData: () => ({}), - cellToString: data => data?.toString() ?? '', - cellFromString: data => { - const num = data ? Number(data) : NaN; - return { - value: isNaN(num) ? null : num, - }; - }, - cellToJson: data => data ?? null, - isEmpty: () => false, - }); diff --git a/packages/affine/microsheet-data-view/src/property-presets/pure-index.ts b/packages/affine/microsheet-data-view/src/property-presets/pure-index.ts index 3987e75fc8f8..069db2290174 100644 --- a/packages/affine/microsheet-data-view/src/property-presets/pure-index.ts +++ b/packages/affine/microsheet-data-view/src/property-presets/pure-index.ts @@ -1,19 +1,5 @@ -import { checkboxPropertyModelConfig } from './checkbox/define.js'; -import { datePropertyModelConfig } from './date/define.js'; -import { imagePropertyModelConfig } from './image/define.js'; -import { multiSelectPropertyModelConfig } from './multi-select/define.js'; -import { numberPropertyModelConfig } from './number/define.js'; -import { progressPropertyModelConfig } from './progress/define.js'; -import { selectPropertyModelConfig } from './select/define.js'; import { textPropertyModelConfig } from './text/define.js'; export const propertyModelPresets = { - checkboxPropertyModelConfig, - datePropertyModelConfig, - imagePropertyModelConfig, - multiSelectPropertyModelConfig, - numberPropertyModelConfig, - progressPropertyModelConfig, - selectPropertyModelConfig, textPropertyModelConfig, }; diff --git a/packages/affine/microsheet-data-view/src/property-presets/select/cell-renderer.ts b/packages/affine/microsheet-data-view/src/property-presets/select/cell-renderer.ts deleted file mode 100644 index f45e9f884b3b..000000000000 --- a/packages/affine/microsheet-data-view/src/property-presets/select/cell-renderer.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { popupTargetFromElement } from '@blocksuite/affine-components/context-menu'; -import { html } from 'lit/static-html.js'; - -import { BaseCellRenderer } from '../../core/property/index.js'; -import { createFromBaseCellRenderer } from '../../core/property/renderer.js'; -import { - popTagSelect, - type SelectTag, -} from '../../core/utils/tags/multi-tag-select.js'; -import { createIcon } from '../../core/utils/uni-icon.js'; -import { - type SelectPropertyData, - selectPropertyModelConfig, -} from './define.js'; - -export class SelectCell extends BaseCellRenderer { - override render() { - const value = this.value ? [this.value] : []; - return html` - - `; - } -} - -export class SelectCellEditing extends BaseCellRenderer< - string, - SelectPropertyData -> { - private popTagSelect = () => { - this._disposables.add({ - dispose: popTagSelect( - popupTargetFromElement( - this.querySelector('affine-microsheet-multi-tag-view') ?? this - ), - { - mode: 'single', - options: this._options, - onOptionsChange: this._onOptionsChange, - value: this._value, - onChange: this._onChange, - onComplete: this._editComplete, - minWidth: 400, - } - ), - }); - }; - - _editComplete = () => { - this.selectCurrentCell(false); - }; - - _onChange = ([id]: string[]) => { - this.onChange(id); - }; - - _onOptionsChange = (options: SelectTag[]) => { - this.property.dataUpdate(data => { - return { - ...data, - options, - }; - }); - }; - - get _options(): SelectTag[] { - return this.property.data$.value.options; - } - - get _value() { - const value = this.value; - return value ? [value] : []; - } - - override firstUpdated() { - this.popTagSelect(); - } - - override render() { - return html` - - `; - } -} - -export const selectPropertyConfig = - selectPropertyModelConfig.createPropertyMeta({ - icon: createIcon('SingleSelectIcon'), - cellRenderer: { - view: createFromBaseCellRenderer(SelectCell), - edit: createFromBaseCellRenderer(SelectCellEditing), - }, - }); diff --git a/packages/affine/microsheet-data-view/src/property-presets/select/define.ts b/packages/affine/microsheet-data-view/src/property-presets/select/define.ts deleted file mode 100644 index e7d53b583df8..000000000000 --- a/packages/affine/microsheet-data-view/src/property-presets/select/define.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { nanoid } from '@blocksuite/store'; - -import type { SelectTag } from '../../core/utils/tags/multi-tag-select.js'; - -import { tTag } from '../../core/logical/data-type.js'; -import { propertyType } from '../../core/property/property-config.js'; -import { getTagColor } from '../../core/utils/tags/colors.js'; - -export const selectPropertyType = propertyType('select'); - -export type SelectPropertyData = { - options: SelectTag[]; -}; -export const selectPropertyModelConfig = selectPropertyType.modelConfig< - string, - SelectPropertyData ->({ - name: 'Select', - type: data => tTag.create({ tags: data.options }), - defaultData: () => ({ - options: [], - }), - addGroup: (text, oldData) => { - return { - options: [ - ...(oldData.options ?? []), - { id: nanoid(), value: text, color: getTagColor() }, - ], - }; - }, - cellToString: (data, colData) => - colData.options.find(v => v.id === data)?.value ?? '', - cellFromString: (data, colData) => { - if (!data) { - return { value: null, data: colData }; - } - const optionMap = Object.fromEntries( - colData.options.map(v => [v.value, v]) - ); - const name = data - .split(',') - .map(v => v.trim()) - .filter(v => v)[0]; - - let value = null; - const option = optionMap[name]; - if (!option) { - const newOption: SelectTag = { - id: nanoid(), - value: name, - color: getTagColor(), - }; - colData.options.push(newOption); - value = newOption.id; - } else { - value = option.id; - } - - return { - value, - data: colData, - }; - }, - cellToJson: data => data ?? null, - isEmpty: data => data == null, -}); diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/cell.ts b/packages/affine/microsheet-data-view/src/view-presets/table/cell.ts index 2c11f77331b3..d4c3c1c46e32 100644 --- a/packages/affine/microsheet-data-view/src/view-presets/table/cell.ts +++ b/packages/affine/microsheet-data-view/src/view-presets/table/cell.ts @@ -12,16 +12,12 @@ import { css, html, nothing } from 'lit'; import { property, state } from 'lit/decorators.js'; import { createRef } from 'lit/directives/ref.js'; -import type { - CellRenderProps, - DataViewCellLifeCycle, -} from '../../core/property/index.js'; +import type { DataViewCellLifeCycle } from '../../core/property/index.js'; import type { SingleView } from '../../core/view-manager/single-view.js'; +import type { TableGroup } from './group.js'; import type { TableColumn } from './table-view-manager.js'; import type { TableViewSelectionWithType } from './types.js'; -import { renderUniLit } from '../../core/index.js'; - export class MicrosheetCellContainer extends SignalWatcher( WithDisposable(ShadowlessElement) ) { @@ -57,31 +53,12 @@ export class MicrosheetCellContainer extends SignalWatcher( return this.column.cellGet(this.rowId); }); - selectCurrentCell = (editing: boolean, focusTo?: 'start' | 'end') => { + selectCurrentCell = (focusTo?: 'start' | 'end') => { if (this.view.readonly$.value) { return; } const selectionView = this.selectionView; if (selectionView) { - // if (selection && this.isSelected(selection) && editing) { - // selectionView.selection = TableAreaSelection.create({ - // groupKey: this.groupKey, - // focus: { - // rowIndex: this.rowIndex, - // columnIndex: this.columnIndex, - // }, - // isEditing: true, - // }); - // } else { - // selectionView.selection = TableAreaSelection.create({ - // groupKey: this.groupKey, - // focus: { - // rowIndex: this.rowIndex, - // columnIndex: this.columnIndex, - // }, - // isEditing: false, - // }); - // } if (selectionView) { this.selectionView.focus = { rowIndex: this.rowIndex, @@ -105,7 +82,7 @@ export class MicrosheetCellContainer extends SignalWatcher( } }; - if (this.children.length === 0) { + if (this.refModel.children.length === 0) { this.std.doc.addBlock( 'affine:paragraph', { @@ -129,11 +106,9 @@ export class MicrosheetCellContainer extends SignalWatcher( } private get groupKey() { - return this.closest('affine-microsheet-data-view-table-group')?.group?.key; - } - - private get readonly() { - return this.column.readonly$.value; + return ( + this.closest('affine-microsheet-data-view-table-group') as TableGroup + )?.group?.key; } get refModel() { @@ -154,16 +129,18 @@ export class MicrosheetCellContainer extends SignalWatcher( override connectedCallback() { super.connectedCallback(); - this._disposables.addFromEvent(this, 'click', e => { + this._disposables.addFromEvent(this, 'click', (e: UIEvent) => { if (!this.isEditing) { if ( e.target && + e.target instanceof HTMLElement && e.target.tagName === 'AFFINE-MICROSHEET-CELL-CONTAINER' ) { - this.selectCurrentCell(!this.column.readonly$.value, 'end'); + this.selectCurrentCell('end'); } else { - this.selectCurrentCell(!this.column.readonly$.value); + this.selectCurrentCell(); } + // this.selectCurrentCell(); } }); } @@ -186,24 +163,6 @@ export class MicrosheetCellContainer extends SignalWatcher( assertExists(this.refModel); return html``; - const renderer = this.column.renderer$.value; - if (!renderer) { - return; - } - const { edit, view } = renderer; - const uni = !this.readonly && this.isEditing && edit != null ? edit : view; - const props: CellRenderProps = { - cell: this.cell$.value, - isEditing: this.isEditing, - selectCurrentCell: this.selectCurrentCell, - }; - - return renderUniLit(uni, props, { - ref: this._cell, - style: { - display: 'contents', - }, - }); } @property({ attribute: false }) diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/components/menu.ts b/packages/affine/microsheet-data-view/src/view-presets/table/components/menu.ts deleted file mode 100644 index 768d1b8795e9..000000000000 --- a/packages/affine/microsheet-data-view/src/view-presets/table/components/menu.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { - menu, - popFilterableSimpleMenu, - type PopupTarget, -} from '@blocksuite/affine-components/context-menu'; -import { - CopyIcon, - DeleteIcon, - ExpandFullIcon, - MoveLeftIcon, - MoveRightIcon, -} from '@blocksuite/icons/lit'; -import { html } from 'lit'; - -import type { DataViewRenderer } from '../../../core/data-view.js'; -import type { TableSelectionController } from '../controller/selection.js'; - -import { TableRowSelection } from '../types.js'; - -export const openDetail = ( - dataViewEle: DataViewRenderer, - rowId: string, - selection: TableSelectionController -) => { - const old = selection.selection; - selection.selection = undefined; - dataViewEle.openDetailPanel({ - view: selection.host.props.view, - rowId: rowId, - onClose: () => { - selection.selection = old; - }, - }); -}; - -export const popRowMenu = ( - dataViewEle: DataViewRenderer, - ele: PopupTarget, - selectionController: TableSelectionController -) => { - const selection = selectionController.selection; - if (!TableRowSelection.is(selection)) { - return; - } - if (selection.rows.length > 1) { - const rows = TableRowSelection.rowsIds(selection); - popFilterableSimpleMenu(ele, [ - menu.group({ - name: '', - items: [ - menu.action({ - name: 'Copy', - prefix: html`
- ${CopyIcon()} -
`, - select: () => { - selectionController.host.clipboardController.copy(); - }, - }), - ], - }), - menu.group({ - name: '', - items: [ - menu.action({ - name: 'Delete Rows', - class: 'delete-item', - prefix: DeleteIcon(), - select: () => { - selectionController.view.rowDelete(rows); - }, - }), - ], - }), - ]); - return; - } - const row = selection.rows[0]; - popFilterableSimpleMenu(ele, [ - menu.action({ - name: 'Expand Row', - prefix: ExpandFullIcon(), - select: () => { - openDetail(dataViewEle, row.id, selectionController); - }, - }), - menu.group({ - name: '', - items: [ - menu.action({ - name: 'Insert Before', - prefix: html`
- ${MoveLeftIcon()} -
`, - select: () => { - selectionController.insertRowBefore(row.groupKey, row.id); - }, - }), - menu.action({ - name: 'Insert After', - prefix: html`
- ${MoveRightIcon()} -
`, - select: () => { - selectionController.insertRowAfter(row.groupKey, row.id); - }, - }), - ], - }), - menu.group({ - name: '', - items: [ - menu.action({ - name: 'Delete Row', - class: 'delete-item', - prefix: DeleteIcon(), - select: () => { - selectionController.deleteRow(row.id); - }, - }), - ], - }), - ]); -}; diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/controller/clipboard.ts b/packages/affine/microsheet-data-view/src/view-presets/table/controller/clipboard.ts index 50b512b36130..291cd233285c 100644 --- a/packages/affine/microsheet-data-view/src/view-presets/table/controller/clipboard.ts +++ b/packages/affine/microsheet-data-view/src/view-presets/table/controller/clipboard.ts @@ -44,11 +44,12 @@ export class TableClipboardController implements ReactiveController { this.std.doc, cellContainerModel.children ); + // @ts-expect-error const item = await this.std.clipboard._getClipboardItem( slice, 'BLOCKSUITE/SNAPSHOT' ); - cell['cellContainerSlice'] = item; + cell['cellContainerSlice'] = item as string; if (isCut) { const children = cellContainerModel.children; children.forEach(b => this.std.doc.deleteBlock(b)); @@ -249,6 +250,7 @@ export class TableClipboardController implements ReactiveController { if (!tableSelection) return false; this._onCut(tableSelection); + return true; }) ); @@ -262,12 +264,13 @@ export class TableClipboardController implements ReactiveController { ); } } + function getSelectedAreaValues( selection: TableViewSelection, table: DataViewTable -): { ref: string; cellContainerSlice: string }[][] { +): { ref: string; cellContainerSlice?: string }[][] { const view = table.props.view; - const rsl: { ref: string; cellContainerSlice: string }[][] = []; + const rsl: { ref: string; cellContainerSlice?: string }[][] = []; const values = getSelectedArea(selection, table); values?.forEach((row, index) => { const cells = row.cells; diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/controller/drag.ts b/packages/affine/microsheet-data-view/src/view-presets/table/controller/drag.ts index 3c21f89e6612..28f1dd12c818 100644 --- a/packages/affine/microsheet-data-view/src/view-presets/table/controller/drag.ts +++ b/packages/affine/microsheet-data-view/src/view-presets/table/controller/drag.ts @@ -20,6 +20,7 @@ export class TableDragController implements ReactiveController { ); const fromGroup = row.groupKey; + // debugger startDrag< | undefined | { @@ -103,9 +104,9 @@ export class TableDragController implements ReactiveController { const mid = (rect.top + rect.bottom) / 2; if (y < rect.bottom) { return { - groupKey: row.groupKey, + groupKey: (row as TableRow).groupKey, position: { - id: row.dataset.rowId as string, + id: (row as TableRow).dataset.rowId as string, before: y < mid, }, y: y < mid ? rect.top : rect.bottom, @@ -148,7 +149,7 @@ export class TableDragController implements ReactiveController { const row = target.closest('microsheet-data-view-table-row'); if (row) { getSelection()?.removeAllRanges(); - this.dragStart(row, event); + this.dragStart(row as TableRow, event); } return true; } diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/controller/hotkeys.ts b/packages/affine/microsheet-data-view/src/view-presets/table/controller/hotkeys.ts index 963079bddbc0..1b9158a005f2 100644 --- a/packages/affine/microsheet-data-view/src/view-presets/table/controller/hotkeys.ts +++ b/packages/affine/microsheet-data-view/src/view-presets/table/controller/hotkeys.ts @@ -1,11 +1,7 @@ import type { ReactiveController } from 'lit'; -import { assertExists } from '@blocksuite/global/utils'; - import type { DataViewTable } from '../table-view.js'; -import { TableRowSelection } from '../types.js'; - export class TableHotkeysController implements ReactiveController { get selectionController() { return this.host.selectionController; @@ -18,377 +14,46 @@ export class TableHotkeysController implements ReactiveController { hostConnected() { this.host.disposables.add( this.host.props.bindHotkey({ - Backspace: () => { - return; - const selection = this.selectionController.selection; - if (!selection) { - return; - } - if (TableRowSelection.is(selection)) { - const rows = TableRowSelection.rowsIds(selection); - this.selectionController.selection = undefined; - this.host.props.view.rowDelete(rows); - return; - } - const { - focus, - rowsSelection, - columnsSelection, - isEditing, - groupKey, - } = selection; - if (focus && !isEditing) { - if (rowsSelection && columnsSelection) { - // multi cell - for (let i = rowsSelection.start; i <= rowsSelection.end; i++) { - const { start, end } = columnsSelection; - for (let j = start; j <= end; j++) { - const container = this.selectionController.getCellContainer( - groupKey, - i, - j - ); - const rowId = container?.dataset.rowId; - const columnId = container?.dataset.columnId; - if (rowId && columnId) { - container?.column.valueSetFromString(rowId, ''); - } - } - } - } else { - // single cell - const container = this.selectionController.getCellContainer( - groupKey, - focus.rowIndex, - focus.columnIndex - ); - const rowId = container?.dataset.rowId; - const columnId = container?.dataset.columnId; - if (rowId && columnId) { - container?.column.valueSetFromString(rowId, ''); - } - } - } - }, - // Escape: () => { - // const selection = this.selectionController.selection; - // if (!selection) { - // return false; - // } - // if (TableRowSelection.is(selection)) { - // const result = this.selectionController.rowsToArea( - // selection.rows.map(v => v.id) - // ); - // if (result) { - // this.selectionController.selection = TableAreaSelection.create({ - // groupKey: result.groupKey, - // focus: { - // rowIndex: result.start, - // columnIndex: 0, - // }, - // rowsSelection: { - // start: result.start, - // end: result.end, - // }, - // isEditing: false, - // }); - // } else { - // this.selectionController.selection = undefined; - // } - // } else if (selection.isEditing) { - // this.selectionController.selection = { - // ...selection, - // isEditing: false, - // }; - // } else { - // const rows = this.selectionController.areaToRows(selection); - // this.selectionController.rowSelectionChange({ - // add: rows, - // remove: [], - // }); - // } - // return true; - // }, - // Enter: context => { - // const selection = this.selectionController.selection; - // if (!selection) { - // return false; - // } - // if (TableRowSelection.is(selection)) { - // const result = this.selectionController.rowsToArea( - // selection.rows.map(v => v.id) - // ); - // if (result) { - // this.selectionController.selection = TableAreaSelection.create({ - // groupKey: result.groupKey, - // focus: { - // rowIndex: result.start, - // columnIndex: 0, - // }, - // rowsSelection: { - // start: result.start, - // end: result.end, - // }, - // isEditing: false, - // }); - // } - // } else if (selection.isEditing) { - // return false; - // } else { - // this.selectionController.selection = { - // ...selection, - // isEditing: true, - // }; - // } - // context.get('keyboardState').raw.preventDefault(); - // return true; - // }, - // 'Shift-Enter': () => { - // const selection = this.selectionController.selection; - // if ( - // !selection || - // TableRowSelection.is(selection) || - // selection.isEditing - // ) { - // return false; - // } - // const cell = this.selectionController.getCellContainer( - // selection.groupKey, - // selection.focus.rowIndex, - // selection.focus.columnIndex - // ); - // if (cell) { - // this.selectionController.insertRowAfter( - // selection.groupKey, - // cell.rowId - // ); - // } - // return true; - // }, - // Tab: ctx => { - // const selection = this.selectionController.selection; - // if ( - // !selection || - // TableRowSelection.is(selection) || - // selection.isEditing - // ) { - // return false; - // } - // ctx.get('keyboardState').raw.preventDefault(); - // this.selectionController.focusToCell('right'); - // return true; - // }, - // 'Shift-Tab': ctx => { - // const selection = this.selectionController.selection; - // if ( - // !selection || - // TableRowSelection.is(selection) || - // selection.isEditing - // ) { - // return false; - // } - // ctx.get('keyboardState').raw.preventDefault(); - // this.selectionController.focusToCell('left'); - // return true; - // }, - // ArrowLeft: context => { - // const selection = this.selectionController.selection; - // if ( - // !selection || - // TableRowSelection.is(selection) || - // selection.isEditing - // ) { - // return false; - // } - // this.selectionController.focusToCell('left'); - // context.get('keyboardState').raw.preventDefault(); - // return true; - // }, - // ArrowRight: context => { - // const selection = this.selectionController.selection; - // if ( - // !selection || - // TableRowSelection.is(selection) || - // selection.isEditing - // ) { - // return false; - // } - // this.selectionController.focusToCell('right'); - // context.get('keyboardState').raw.preventDefault(); - // return true; - // }, - // ArrowUp: context => { - // const selection = this.selectionController.selection; - // if (!selection) { - // return false; - // } - - // if (TableRowSelection.is(selection)) { - // this.selectionController.navigateRowSelection('up', false); - // } else if (selection.isEditing) { - // return false; - // } else { - // this.selectionController.focusToCell('up'); - // } - - // context.get('keyboardState').raw.preventDefault(); - // return true; - // }, - // ArrowDown: context => { - // const selection = this.selectionController.selection; - // if (!selection) { - // return false; - // } - - // if (TableRowSelection.is(selection)) { - // this.selectionController.navigateRowSelection('down', false); - // } else if (selection.isEditing) { - // return false; - // } else { - // this.selectionController.focusToCell('down'); - // } - - // context.get('keyboardState').raw.preventDefault(); - // return true; - // }, - - // 'Shift-ArrowUp': context => { - // const selection = this.selectionController.selection; - // if (!selection) { - // return false; - // } - - // if (TableRowSelection.is(selection)) { - // this.selectionController.navigateRowSelection('up', true); - // } else if (selection.isEditing) { - // return false; - // } else { - // this.selectionController.selectionAreaUp(); - // } - - // context.get('keyboardState').raw.preventDefault(); - // return true; - // }, - - // 'Shift-ArrowDown': context => { - // const selection = this.selectionController.selection; - // if (!selection) { - // return false; - // } - - // if (TableRowSelection.is(selection)) { - // this.selectionController.navigateRowSelection('down', true); - // } else if (selection.isEditing) { - // return false; - // } else { - // this.selectionController.selectionAreaDown(); - // } - - // context.get('keyboardState').raw.preventDefault(); - // return true; - // }, - - // 'Shift-ArrowLeft': context => { - // const selection = this.selectionController.selection; - // if ( - // !selection || - // TableRowSelection.is(selection) || - // selection.isEditing || - // this.selectionController.isRowSelection() - // ) { - // return false; - // } - - // this.selectionController.selectionAreaLeft(); - - // context.get('keyboardState').raw.preventDefault(); - // return true; - // }, - - // 'Shift-ArrowRight': context => { - // const selection = this.selectionController.selection; - // if ( - // !selection || - // TableRowSelection.is(selection) || - // selection.isEditing || - // this.selectionController.isRowSelection() - // ) { - // return false; - // } - - // this.selectionController.selectionAreaRight(); - - // context.get('keyboardState').raw.preventDefault(); - // return true; - // }, - - 'Mod-a': context => { - const selection = this.selectionController.selection; - if (TableRowSelection.is(selection)) { - return false; - } - if (!selection) { - const microsheet = this.host.closest('affine-microsheet'); - assertExists(microsheet); - const stdSelection = this.host.std.selection; - - stdSelection.set([ - stdSelection.create('block', { blockId: microsheet.blockId }), - ]); - return true; - } - if (selection?.isEditing) { - return true; - } - if (selection) { - context.get('keyboardState').raw.preventDefault(); - this.selectionController.selection = TableRowSelection.create({ - rows: - this.host.props.view.groupManager.groupsDataList$.value?.flatMap( - group => group.rows.map(id => ({ groupKey: group.key, id })) - ) ?? - this.host.props.view.rows$.value.map(id => ({ - groupKey: undefined, - id, - })), - }); - return true; - } + 'Mod-a': () => { return; + // const selection = this.selectionController.selection; + // if (TableRowSelection.is(selection)) { + // return false; + // } + // if (!selection) { + // const microsheet = this.host.closest('affine-microsheet'); + // assertExists(microsheet); + // if (!(microsheet instanceof CaptionedBlockComponent)) { + // return false; + // } + // const stdSelection = this.host.std.selection; + + // stdSelection.set([ + // stdSelection.create('block', { + // blockId: microsheet.blockId, + // }), + // ]); + // return true; + // } + // if (selection?.isEditing) { + // return true; + // } + // if (selection) { + // context.get('keyboardState').raw.preventDefault(); + // this.selectionController.selection = TableRowSelection.create({ + // rows: + // this.host.props.view.groupManager.groupsDataList$.value?.flatMap( + // group => group.rows.map(id => ({ groupKey: group.key, id })) + // ) ?? + // this.host.props.view.rows$.value.map(id => ({ + // groupKey: undefined, + // id, + // })), + // }); + // return true; + // } + // return; }, - // '/': context => { - // const selection = this.selectionController.selection; - // if (!selection) { - // return; - // } - // if (TableRowSelection.is(selection)) { - // // open multi-rows context-menu - // return; - // } - // if (selection.isEditing) { - // return; - // } - // const cell = this.selectionController.getCellContainer( - // selection.groupKey, - // selection.focus.rowIndex, - // selection.focus.columnIndex - // ); - // if (cell) { - // context.get('keyboardState').raw.preventDefault(); - // const row = { - // id: cell.rowId, - // groupKey: selection.groupKey, - // }; - // this.selectionController.selection = TableRowSelection.create({ - // rows: [row], - // }); - // popRowMenu( - // this.host.props.dataViewEle, - // popupTargetFromElement(cell), - // this.selectionController - // ); - // } - // }, }) ); } diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/controller/selection.ts b/packages/affine/microsheet-data-view/src/view-presets/table/controller/selection.ts index 7f93e5db396f..176ef115320a 100644 --- a/packages/affine/microsheet-data-view/src/view-presets/table/controller/selection.ts +++ b/packages/affine/microsheet-data-view/src/view-presets/table/controller/selection.ts @@ -9,15 +9,16 @@ import { property } from 'lit/decorators.js'; import { createRef, ref } from 'lit/directives/ref.js'; import type { MicrosheetCellContainer } from '../cell.js'; -import type { TableRow } from '../row/row.js'; +import type { TableGroup } from '../group.js'; import type { DataViewTable } from '../table-view.js'; import { startDrag } from '../../../core/utils/drag.js'; import { autoScrollOnBoundary } from '../../../core/utils/frame-loop.js'; +import { TableRow } from '../row/row.js'; import { type CellFocus, type MultiSelection, - RowWithGroup, + type RowWithGroup, TableAreaSelection, TableRowSelection, type TableViewSelection, @@ -291,7 +292,7 @@ export class TableSelectionController implements ReactiveController { length: selection.rowsSelection.end - selection.rowsSelection.start + 1, }) .map((_, index) => index + selection.rowsSelection.start) - .map(row => rows[row]?.rowId); + .map(row => (rows[row] as TableRow)?.rowId); return ids.map(id => ({ id, groupKey: selection.groupKey })); } @@ -345,7 +346,8 @@ export class TableSelectionController implements ReactiveController { deleteRow(rowId: string) { this.view.rowDelete([rowId]); - this.focusToCell('up'); + // this.focusToCell('up'); + this.clearSelection(); } focusFirstCell() { @@ -442,7 +444,6 @@ export class TableSelectionController implements ReactiveController { ?.querySelectorAll('affine-microsheet-cell-container') ?.item(columnIndex) ?.selectCurrentCell( - false, focusTo ? focusTo : position === 'up' || position === 'left' @@ -561,63 +562,6 @@ export class TableSelectionController implements ReactiveController { return true; } - navigateRowSelection(direction: 'up' | 'down', append = false) { - return; - if (!TableRowSelection.is(this.selection)) return; - const rows = this.selection.rows; - const lastRow = rows[rows.length - 1]; - const lastRowIndex = - ( - this.getGroup(lastRow.groupKey)?.querySelector( - `data-view-table-row[data-row-id='${lastRow.id}']` - ) as TableRow | null - )?.rowIndex ?? 0; - const getRowByIndex = (index: number) => { - const tableRow = this.rows(lastRow.groupKey)?.item(index); - if (!tableRow) { - return; - } - return { - id: tableRow.rowId, - groupKey: lastRow.groupKey, - }; - }; - const prevRow = getRowByIndex(lastRowIndex - 1); - const nextRow = getRowByIndex(lastRowIndex + 1); - const includes = (row: RowWithGroup) => { - if (!row) { - return false; - } - return rows.some(r => RowWithGroup.equal(r, row)); - }; - if (append) { - const addList: RowWithGroup[] = []; - const removeList: RowWithGroup[] = []; - if (direction === 'up' && prevRow != null) { - if (includes(prevRow)) { - removeList.push(lastRow); - } else { - addList.push(prevRow); - } - } - if (direction === 'down' && nextRow != null) { - if (includes(nextRow)) { - removeList.push(lastRow); - } else { - addList.push(nextRow); - } - } - this.rowSelectionChange({ add: addList, remove: removeList }); - } else { - const target = direction === 'up' ? prevRow : nextRow; - if (target != null) { - this.selection = TableRowSelection.create({ - rows: [target], - }); - } - } - } - rows(groupKey: string | undefined) { const container = groupKey != null @@ -663,6 +607,9 @@ export class TableSelectionController implements ReactiveController { for (const row of this.tableContainer ?.querySelectorAll('microsheet-data-view-table-row') .values() ?? []) { + if (!(row instanceof TableRow)) { + continue; + } if (!set.has(row.rowId)) { continue; } @@ -822,8 +769,9 @@ export class TableSelectionController implements ReactiveController { cell: MicrosheetCellContainer, fillValues?: boolean ) { - const groupKey = cell.closest('affine-microsheet-data-view-table-group') - ?.group?.key; + const groupKey = ( + cell.closest('affine-microsheet-data-view-table-group') as TableGroup + )?.group?.key; const table = this.tableContainer; const scrollContainer = table?.parentElement; if (!table || !scrollContainer) { @@ -894,6 +842,7 @@ export class TableSelectionController implements ReactiveController { select(selection); return selection; }, + // @ts-expect-error onDrop: selection => { if (!selection) { return; diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/define.ts b/packages/affine/microsheet-data-view/src/view-presets/table/define.ts index f140f0193516..6a3572aa2da6 100644 --- a/packages/affine/microsheet-data-view/src/view-presets/table/define.ts +++ b/packages/affine/microsheet-data-view/src/view-presets/table/define.ts @@ -1,4 +1,3 @@ -import type { FilterGroup } from '../../core/common/ast.js'; import type { GroupBy, GroupProperty, Sort } from '../../core/common/types.js'; import { type BasicViewDataType, viewType } from '../../core/view/data-view.js'; @@ -14,7 +13,6 @@ export type TableViewColumn = { }; type DataType = { columns: TableViewColumn[]; - filter: FilterGroup; groupBy?: GroupBy; groupProperties?: GroupProperty[]; sort?: Sort; diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/group.ts b/packages/affine/microsheet-data-view/src/view-presets/table/group.ts index cffb2ebaac40..1e878c6b90ee 100644 --- a/packages/affine/microsheet-data-view/src/view-presets/table/group.ts +++ b/packages/affine/microsheet-data-view/src/view-presets/table/group.ts @@ -3,9 +3,8 @@ import { popFilterableSimpleMenu, popupTargetFromElement, } from '@blocksuite/affine-components/context-menu'; -import { ShadowlessElement } from '@blocksuite/block-std'; +import { BlockComponent, ShadowlessElement } from '@blocksuite/block-std'; import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; -import { PlusIcon } from '@blocksuite/icons/lit'; import { css, html, type PropertyValues } from 'lit'; import { property } from 'lit/decorators.js'; import { repeat } from 'lit/directives/repeat.js'; @@ -61,24 +60,6 @@ export class TableGroup extends SignalWatcher( ) { static override styles = styles; - private clickAddRow = () => { - this.view.rowAdd('end', this.group?.key); - requestAnimationFrame(() => { - const selectionController = this.viewEle.selectionController; - const index = this.view.properties$.value.findIndex( - v => v.type$.value === 'title' - ); - selectionController.selection = TableAreaSelection.create({ - groupKey: this.group?.key, - focus: { - rowIndex: this.rows.length - 1, - columnIndex: index, - }, - isEditing: true, - }); - }); - }; - private clickAddRowInStart = () => { this.view.rowAdd('start', this.group?.key); requestAnimationFrame(() => { @@ -164,44 +145,6 @@ export class TableGroup extends SignalWatcher( )}
`; - return html` - -
- ${repeat( - ids, - id => id, - (id, idx) => { - return html``; - } - )} -
- ${this.view.readonly$.value - ? null - : html`
-
- ${PlusIcon()}New Record -
-
`} - - - `; } override render() { @@ -211,7 +154,9 @@ export class TableGroup extends SignalWatcher( protected override updated(_changedProperties: PropertyValues) { super.updated(_changedProperties); this.querySelectorAll('microsheet-data-view-table-row').forEach(ele => { - ele.requestUpdate(); + if (ele instanceof BlockComponent) { + ele.requestUpdate(); + } }); } diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/header/column-header.ts b/packages/affine/microsheet-data-view/src/view-presets/table/header/column-header.ts index 3f6c3e947e68..8ff2eefdd9e7 100644 --- a/packages/affine/microsheet-data-view/src/view-presets/table/header/column-header.ts +++ b/packages/affine/microsheet-data-view/src/view-presets/table/header/column-header.ts @@ -18,16 +18,6 @@ export class MicrosheetColumnHeader extends SignalWatcher( ) { static override styles = styles; - private _onAddColumn = (e: MouseEvent) => { - if (this.readonly) return; - this.tableViewManager.propertyAdd('end'); - const ele = e.currentTarget as HTMLElement; - requestAnimationFrame(() => { - this.editLastColumnTitle(); - ele.scrollIntoView({ block: 'nearest', inline: 'nearest' }); - }); - }; - editLastColumnTitle = () => { const columns = this.querySelectorAll('affine-microsheet-header-column'); const column = columns.item(columns.length - 1); diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/header/microsheet-header-column.ts b/packages/affine/microsheet-data-view/src/view-presets/table/header/microsheet-header-column.ts index d191292126d4..5a836eb52236 100644 --- a/packages/affine/microsheet-data-view/src/view-presets/table/header/microsheet-header-column.ts +++ b/packages/affine/microsheet-data-view/src/view-presets/table/header/microsheet-header-column.ts @@ -19,7 +19,6 @@ import { html } from 'lit/static-html.js'; import type { TableColumn, TableSingleView } from '../table-view-manager.js'; -import { renderUniLit } from '../../../core/index.js'; import { startDrag } from '../../../core/utils/drag.js'; import { autoScrollOnBoundary } from '../../../core/utils/frame-loop.js'; import { getResultInRange } from '../../../core/utils/utils.js'; @@ -52,30 +51,6 @@ export class MicrosheetHeaderColumn extends SignalWatcher( this.popMenu(); }; - private _clickTypeIcon = (event: MouseEvent) => { - if (this.tableViewManager.readonly$.value) { - return; - } - if (this.column.type$.value === 'title') { - return; - } - event.stopPropagation(); - popMenu(popupTargetFromElement(this), { - options: { - items: this.tableViewManager.propertyMetas.map(config => { - return menu.action({ - name: config.config.name, - isSelected: config.type === this.column.type$.value, - prefix: renderUniLit(this.tableViewManager.IconGet(config.type)), - select: () => { - this.column.typeSet?.(config.type); - }, - }); - }), - }, - }); - }; - private _columnsOffset = (header: Element, _scale: number) => { const columns = header.querySelectorAll('affine-microsheet-header-column'); const left: ColumnOffset[] = []; @@ -315,7 +290,7 @@ export class MicrosheetHeaderColumn extends SignalWatcher( select: () => { this.column.delete?.(); }, - class: 'delete-item', + class: { 'delete-item': true }, }), ], }), diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/header/number-format-bar.ts b/packages/affine/microsheet-data-view/src/view-presets/table/header/number-format-bar.ts deleted file mode 100644 index baf8bfbcc228..000000000000 --- a/packages/affine/microsheet-data-view/src/view-presets/table/header/number-format-bar.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { WithDisposable } from '@blocksuite/global/utils'; -import { css, html, LitElement } from 'lit'; -import { property } from 'lit/decorators.js'; - -import type { Property } from '../../../core/view-manager/property.js'; - -import { formatNumber } from '../../../property-presets/number/utils/formatter.js'; - -const IncreaseDecimalPlacesIcon = html` - - - -`; - -const DecreaseDecimalPlacesIcon = html` - - - -`; - -export class MicrosheetNumberFormatBar extends WithDisposable(LitElement) { - static override styles = css` - .number-format-toolbar-container { - padding: 4px 12px; - display: flex; - gap: 7px; - flex-direction: column; - } - - .number-format-decimal-places { - display: flex; - gap: 4px; - align-items: center; - justify-content: flex-start; - } - - .number-format-toolbar-button { - box-sizing: border-box; - background-color: transparent; - border: none; - border-radius: 4px; - color: var(--affine-icon-color); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - padding: 4px; - position: relative; - - user-select: none; - } - - .number-format-toolbar-button svg { - width: 16px; - height: 16px; - } - - .number-formatting-sample { - font-size: var(--affine-font-xs); - color: var(--affine-icon-color); - margin-left: auto; - } - .number-format-toolbar-button:hover { - background-color: var(--affine-hover-color); - } - .divider { - width: 100%; - height: 1px; - background-color: var(--affine-border-color); - } - `; - - private _decrementDecimalPlaces = () => { - this.column.dataUpdate(data => ({ - decimal: Math.max(((data.decimal as number) ?? 0) - 1, 0), - })); - this.requestUpdate(); - }; - - private _incrementDecimalPlaces = () => { - this.column.dataUpdate(data => ({ - decimal: Math.min(((data.decimal as number) ?? 0) + 1, 8), - })); - this.requestUpdate(); - }; - - override render() { - return html` -
-
- - - - - ( ${formatNumber( - 1, - 'number', - (this.column.data$.value.decimal as number) ?? 0 - )} ) - -
-
-
- `; - } - - @property({ attribute: false }) - accessor column!: Property; -} - -declare global { - interface HTMLElementTagNameMap { - 'affine-microsheet-number-format-bar': MicrosheetNumberFormatBar; - } -} diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/row/row.ts b/packages/affine/microsheet-data-view/src/view-presets/table/row/row.ts index 8221961771b2..941c98fe0156 100644 --- a/packages/affine/microsheet-data-view/src/view-presets/table/row/row.ts +++ b/packages/affine/microsheet-data-view/src/view-presets/table/row/row.ts @@ -20,7 +20,6 @@ import { html } from 'lit/static-html.js'; import type { DataViewRenderer } from '../../../core/data-view.js'; import type { TableSingleView } from '../table-view-manager.js'; -import { openDetail, popRowMenu } from '../components/menu.js'; import { DEFAULT_COLUMN_MIN_WIDTH } from '../consts.js'; import { TableRowSelection, type TableViewSelection } from '../types.js'; @@ -198,20 +197,12 @@ export class TableRow extends SignalWatcher(WithDisposable(ShadowlessElement)) { return; } e.preventDefault(); - const ele = e.target as HTMLElement; - const cell = ele.closest('affine-microsheet-cell-container'); const row = { id: this.rowId, groupKey: this.groupKey }; if (!TableRowSelection.includes(selection.selection, row)) { selection.selection = TableRowSelection.create({ rows: [row], }); } - const target = - cell ?? - (e.target as HTMLElement).closest('.microsheet-cell') ?? // for last add btn cell - (e.target as HTMLElement); - - popRowMenu(this.dataViewEle, popupTargetFromElement(target), selection); }; setSelection = (selection?: TableViewSelection) => { @@ -243,7 +234,7 @@ export class TableRow extends SignalWatcher(WithDisposable(ShadowlessElement)) { selection.deleteRow(this.rowId); } }, - class: 'delete-item', + class: { 'delete-item': true }, }), ], }), @@ -304,24 +295,17 @@ export class TableRow extends SignalWatcher(WithDisposable(ShadowlessElement)) { rows: [{ id: this.rowId, groupKey: this.groupKey }], }) ); - openDetail(this.dataViewEle, this.rowId, this.selectionController); }; - const openMenu = (e: MouseEvent) => { + const openMenu = () => { if (!this.selectionController) { return; } - const ele = e.currentTarget as HTMLElement; const row = { id: this.rowId, groupKey: this.groupKey }; this.setSelection( TableRowSelection.create({ rows: [row], }) ); - popRowMenu( - this.dataViewEle, - popupTargetFromElement(ele), - this.selectionController - ); }; return html`
@@ -341,6 +325,7 @@ export class TableRow extends SignalWatcher(WithDisposable(ShadowlessElement)) { .columnIndex="${i}" data-column-index="${i}" .std="${this.std}" + contenteditable="${true}" >
@@ -382,6 +367,6 @@ export class TableRow extends SignalWatcher(WithDisposable(ShadowlessElement)) { declare global { interface HTMLElementTagNameMap { - 'data-view-table-row': TableRow; + 'microsheet-data-view-table-row': TableRow; } } diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/stats/column-stats-bar.ts b/packages/affine/microsheet-data-view/src/view-presets/table/stats/column-stats-bar.ts deleted file mode 100644 index fcea58853b4c..000000000000 --- a/packages/affine/microsheet-data-view/src/view-presets/table/stats/column-stats-bar.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { ShadowlessElement } from '@blocksuite/block-std'; -import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; -import { css, html } from 'lit'; -import { property } from 'lit/decorators.js'; -import { repeat } from 'lit/directives/repeat.js'; - -import type { GroupData } from '../../../core/common/group-by/helper.js'; -import type { TableSingleView } from '../table-view-manager.js'; - -import { LEFT_TOOL_BAR_WIDTH, STATS_BAR_HEIGHT } from '../consts.js'; - -const styles = css` - .affine-microsheet-column-stats { - width: 100%; - margin-left: ${LEFT_TOOL_BAR_WIDTH}px; - height: ${STATS_BAR_HEIGHT}px; - display: flex; - } -`; - -export class MicrosheetColumnStats extends SignalWatcher( - WithDisposable(ShadowlessElement) -) { - static override styles = styles; - - protected override render() { - const cols = this.view.properties$.value; - - return html` -
- ${repeat( - cols, - col => col.id, - col => { - return html``; - } - )} -
- `; - } - - @property({ attribute: false }) - accessor group: GroupData | undefined = undefined; - - @property({ attribute: false }) - accessor view!: TableSingleView; -} - -declare global { - interface HTMLElementTagNameMap { - 'affine-microsheet-column-stats': MicrosheetColumnStats; - } -} diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/stats/column-stats-column.ts b/packages/affine/microsheet-data-view/src/view-presets/table/stats/column-stats-column.ts deleted file mode 100644 index 1efb120f3218..000000000000 --- a/packages/affine/microsheet-data-view/src/view-presets/table/stats/column-stats-column.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { - menu, - type MenuConfig, - popFilterableSimpleMenu, - popupTargetFromElement, -} from '@blocksuite/affine-components/context-menu'; -import { ShadowlessElement } from '@blocksuite/block-std'; -import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; -import { ArrowDownSmallIcon } from '@blocksuite/icons/lit'; -import { Text } from '@blocksuite/store'; -import { computed, signal } from '@preact/signals-core'; -import { css, html } from 'lit'; -import { property } from 'lit/decorators.js'; -import { styleMap } from 'lit/directives/style-map.js'; - -import type { GroupData } from '../../../core/common/group-by/helper.js'; -import type { StatsFunction } from '../../../core/common/stats/type.js'; -import type { TableColumn } from '../table-view-manager.js'; - -import { statsFunctions } from '../../../core/common/stats/index.js'; -import { typesystem } from '../../../core/logical/typesystem.js'; -import { DEFAULT_COLUMN_MIN_WIDTH } from '../consts.js'; - -const styles = css` - .stats-cell { - cursor: pointer; - transition: opacity 230ms ease; - font-size: 12px; - color: var(--affine-text-secondary-color); - display: flex; - opacity: 0; - min-width: ${DEFAULT_COLUMN_MIN_WIDTH}px; - justify-content: flex-end; - height: 100%; - align-items: center; - } - - .affine-microsheet-column-stats:hover .stats-cell { - opacity: 1; - } - - .stats-cell:hover { - background-color: var(--affine-hover-color); - cursor: pointer; - } - - .stats-cell[calculated='true'] { - opacity: 1; - } - - .stats-cell .content { - display: flex; - align-items: center; - justify-content: center; - gap: 0.2rem; - margin-inline: 5px; - } - - .label { - text-transform: uppercase; - color: var(--affine-text-secondary-color); - } - - .value { - color: var(--affine-text-primary-color); - } -`; - -export class MicrosheetColumnStatsCell extends SignalWatcher( - WithDisposable(ShadowlessElement) -) { - static override styles = styles; - - @property({ attribute: false }) - accessor column!: TableColumn; - - cellValues$ = computed(() => { - if (this.group) { - return this.group.rows.map(id => { - return this.column.valueGet(id); - }); - } - return this.column.cells$.value.map(cell => cell.value$.value); - }); - - groups$ = computed(() => { - const groups: Record> = {}; - - statsFunctions.forEach(func => { - if (!typesystem.isSubtype(func.dataType, this.column.dataType$.value)) { - return; - } - if (!groups[func.group]) { - groups[func.group] = {}; - } - const oldFunc = groups[func.group][func.type]; - if (!oldFunc || typesystem.isSubtype(oldFunc.dataType, func.dataType)) { - if (!func.impl) { - delete groups[func.group][func.type]; - } else { - groups[func.group][func.type] = func; - } - } - }); - return groups; - }); - - openMenu = (ev: MouseEvent) => { - const menus: MenuConfig[] = Object.entries(this.groups$.value).map( - ([group, funcs]) => { - return menu.subMenu({ - name: group, - options: { - items: Object.values(funcs).map(func => { - return menu.action({ - isSelected: func.type === this.column.statCalcOp$.value, - name: func.menuName ?? func.type, - select: () => { - this.column.updateStatCalcOp(func.type); - }, - }); - }), - }, - }); - } - ); - popFilterableSimpleMenu(popupTargetFromElement(ev.target as HTMLElement), [ - menu.action({ - isSelected: !this.column.statCalcOp$.value, - name: 'None', - select: () => { - this.column.updateStatCalcOp(); - }, - }), - ...menus, - ]); - }; - - statsFunc$ = computed(() => { - return Object.values(this.groups$.value) - .flatMap(group => Object.values(group)) - .find(func => func.type === this.column.statCalcOp$.value); - }); - - values$ = signal([]); - - statsResult$ = computed(() => { - const meta = this.column.view.propertyMetaGet(this.column.type$.value); - if (!meta) { - return null; - } - const func = this.statsFunc$.value; - if (!func) { - return null; - } - return { - name: func.displayName, - value: func.impl?.(this.values$.value, { meta }) ?? '', - }; - }); - - subscriptionMap = new Map void>(); - - override connectedCallback(): void { - super.connectedCallback(); - this.disposables.addFromEvent(this, 'click', this.openMenu); - this.disposables.add( - this.cellValues$.subscribe(values => { - const map = new Map void>(); - values.forEach(value => { - if (value instanceof Text) { - const unsub = this.subscriptionMap.get(value); - if (unsub) { - map.set(value, unsub); - this.subscriptionMap.delete(value); - } else { - const f = () => { - this.values$.value = [...this.cellValues$.value]; - }; - value.yText.observe(f); - map.set(value, () => { - value.yText.unobserve(f); - }); - } - } - }); - this.subscriptionMap.forEach(unsub => { - unsub(); - }); - this.subscriptionMap = map; - this.values$.value = this.cellValues$.value; - }) - ); - this.disposables.add(() => { - this.subscriptionMap.forEach(unsub => { - unsub(); - }); - }); - } - - protected override render() { - const style = { - width: `${this.column.width$.value}px`, - }; - return html`
-
- ${!this.statsResult$.value - ? html`Calculate ${ArrowDownSmallIcon()}` - : html` - ${this.statsResult$.value.name} - ${this.statsResult$.value.value} - `} -
-
`; - } - - @property({ attribute: false }) - accessor group: GroupData | undefined = undefined; -} - -declare global { - interface HTMLElementTagNameMap { - 'affine-microsheet-column-stats-cell': MicrosheetColumnStatsCell; - } -} diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/table-view-manager.ts b/packages/affine/microsheet-data-view/src/view-presets/table/table-view-manager.ts index 7e4298fd57d3..fab201f0d58b 100644 --- a/packages/affine/microsheet-data-view/src/view-presets/table/table-view-manager.ts +++ b/packages/affine/microsheet-data-view/src/view-presets/table/table-view-manager.ts @@ -8,13 +8,11 @@ import type { ViewManager } from '../../core/view-manager/view-manager.js'; import type { TableViewData } from './define.js'; import type { StatCalcOpType } from './types.js'; -import { emptyFilterGroup, type FilterGroup } from '../../core/common/ast.js'; import { defaultGroupBy } from '../../core/common/group-by.js'; import { GroupManager, sortByManually, } from '../../core/common/group-by/helper.js'; -import { evalFilter } from '../../core/logical/eval-filter.js'; import { PropertyBase } from '../../core/view-manager/property.js'; import { type SingleView, @@ -54,10 +52,6 @@ export class TableSingleView extends SingleViewBase { }); }); - filter$ = computed(() => { - return this.data$.value?.filter ?? emptyFilterGroup; - }); - groupBy$ = computed(() => { return this.data$.value?.groupBy; }); @@ -225,24 +219,7 @@ export class TableSingleView extends SingleViewBase { }); } - filterSet(filter: FilterGroup): void { - this.dataUpdate(() => { - return { - filter, - }; - }); - } - - isShow(rowId: string): boolean { - if (this.filter$.value?.conditions.length) { - const rowMap = Object.fromEntries( - this.properties$.value.map(column => [ - column.id, - column.cellGet(rowId).jsonValue$.value, - ]) - ); - return evalFilter(this.filter$.value, rowMap); - } + override isShow(): boolean { return true; } diff --git a/packages/affine/microsheet-data-view/src/view-presets/table/table-view.ts b/packages/affine/microsheet-data-view/src/view-presets/table/table-view.ts index f13ecf30c094..d170de84b449 100644 --- a/packages/affine/microsheet-data-view/src/view-presets/table/table-view.ts +++ b/packages/affine/microsheet-data-view/src/view-presets/table/table-view.ts @@ -59,7 +59,6 @@ const styles = css` .affine-microsheet-block-table { position: relative; width: 100%; - padding-bottom: 4px; z-index: 1; /* overflow-x: scroll; overflow-y: hidden; */ diff --git a/packages/affine/microsheet-data-view/src/widget-presets/filter/condition.ts b/packages/affine/microsheet-data-view/src/widget-presets/filter/condition.ts deleted file mode 100644 index 967d78a6866c..000000000000 --- a/packages/affine/microsheet-data-view/src/widget-presets/filter/condition.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { - menu, - popFilterableSimpleMenu, - type PopupTarget, - popupTargetFromElement, -} from '@blocksuite/affine-components/context-menu'; -import { ShadowlessElement } from '@blocksuite/block-std'; -import { SignalWatcher } from '@blocksuite/global/utils'; -import { CloseIcon } from '@blocksuite/icons/lit'; -import { computed } from '@preact/signals-core'; -import { css, html, nothing } from 'lit'; -import { property } from 'lit/decorators.js'; -import { repeat } from 'lit/directives/repeat.js'; - -import { - type FilterGroup, - firstFilter, - firstFilterByRef, - firstFilterInGroup, - getRefType, - type SingleFilter, - type Variable, - type VariableOrProperty, -} from '../../core/common/ast.js'; -import { - popLiteralEdit, - renderLiteral, -} from '../../core/common/literal/matcher.js'; -import { tBoolean } from '../../core/logical/data-type.js'; -import { typesystem } from '../../core/logical/typesystem.js'; -import { filterMatcher } from './matcher/matcher.js'; - -export class FilterConditionView extends SignalWatcher(ShadowlessElement) { - static override styles = css` - microsheet-filter-condition-view { - display: flex; - align-items: center; - padding: 4px; - gap: 16px; - border: 1px solid var(--affine-border-color); - border-radius: 8px; - background-color: var(--affine-white); - } - - .filter-condition-expression { - display: flex; - align-items: center; - gap: 4px; - } - - .filter-condition-delete { - border-radius: 4px; - display: flex; - align-items: center; - justify-content: center; - height: max-content; - cursor: pointer; - } - - .filter-condition-delete:hover { - background-color: var(--affine-hover-color); - } - - .filter-condition-delete svg { - width: 16px; - height: 16px; - } - - .filter-condition-function-name { - font-size: 12px; - line-height: 20px; - color: var(--affine-text-secondary-color); - padding: 2px 8px; - border-radius: 4px; - cursor: pointer; - } - - .filter-condition-function-name:hover { - background-color: var(--affine-hover-color); - } - - .filter-condition-arg { - font-size: 12px; - font-style: normal; - font-weight: 600; - padding: 0 4px; - height: 100%; - display: flex; - align-items: center; - } - `; - - private _setRef = (ref: VariableOrProperty) => { - this.setData(firstFilterByRef(this.vars, ref)); - }; - - private _args() { - const fn = filterMatcher.find(v => v.data.name === this.data.function); - if (!fn) { - return []; - } - const refType = getRefType(this.vars, this.data.left); - if (!refType) { - return []; - } - const type = typesystem.instance({}, [refType], tBoolean.create(), fn.type); - return type.args.slice(1); - } - - private _filterLabel() { - return filterMatcher.find(v => v.data.name === this.data.function)?.data - .label; - } - - private _filterList() { - const type = getRefType(this.vars, this.data.left); - if (!type) { - return []; - } - return filterMatcher.allMatchedData(type); - } - - private _selectFilter(e: MouseEvent) { - const target = e.currentTarget as HTMLElement; - const list = this._filterList(); - popFilterableSimpleMenu( - popupTargetFromElement(target), - list.map(v => { - const selected = v.name === this.data.function; - return menu.action({ - name: v.label, - isSelected: selected, - select: () => { - this.setData({ - ...this.data, - function: v.name, - }); - }, - }); - }) - ); - } - - override render() { - const data = this.data; - - return html` -
- -
- ${this._filterLabel()} -
- ${repeat(this._args(), (type, i) => { - const value$ = computed(() => { - return this.data.args[i]?.value; - }); - const onChange = (value: unknown) => { - const newArr = this.data.args.slice(); - newArr[i] = { type: 'literal', value }; - this.setData({ - ...this.data, - args: newArr, - }); - }; - const click = (e: MouseEvent) => { - popLiteralEdit( - popupTargetFromElement(e.currentTarget as HTMLElement), - type, - value$, - onChange - ); - }; - return html`
- ${renderLiteral(type, value$, onChange)} -
`; - })} -
- ${this.onDelete - ? html`
- ${CloseIcon()} -
` - : nothing} - `; - } - - @property({ attribute: false }) - accessor data!: SingleFilter; - - @property({ attribute: false }) - accessor onDelete: (() => void) | undefined = undefined; - - @property({ attribute: false }) - accessor setData!: (filter: SingleFilter) => void; - - @property({ attribute: false }) - accessor vars!: Variable[]; -} - -declare global { - interface HTMLElementTagNameMap { - 'microsheet-filter-condition-view': FilterConditionView; - } -} -export const popAddNewFilter = ( - target: PopupTarget, - props: { - value: FilterGroup; - onChange: (value: FilterGroup) => void; - vars: Variable[]; - } -) => { - popFilterableSimpleMenu(target, [ - menu.action({ - name: 'Add filter', - select: () => { - props.onChange({ - ...props.value, - conditions: [...props.value.conditions, firstFilter(props.vars)], - }); - }, - }), - menu.action({ - name: 'Add filter group', - select: () => { - props.onChange({ - ...props.value, - conditions: [ - ...props.value.conditions, - firstFilterInGroup(props.vars), - ], - }); - }, - }), - ]); -}; diff --git a/packages/affine/microsheet-data-view/src/widget-presets/filter/context.ts b/packages/affine/microsheet-data-view/src/widget-presets/filter/context.ts deleted file mode 100644 index cfba2e323315..000000000000 --- a/packages/affine/microsheet-data-view/src/widget-presets/filter/context.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { signal, type Signal } from '@preact/signals-core'; - -import { createContextKey } from '../../core/index.js'; - -export const ShowFilterContextKey = createContextKey< - Signal> ->('show-filter', signal({})); diff --git a/packages/affine/microsheet-data-view/src/widget-presets/filter/filter-bar.ts b/packages/affine/microsheet-data-view/src/widget-presets/filter/filter-bar.ts deleted file mode 100644 index 752a859517b4..000000000000 --- a/packages/affine/microsheet-data-view/src/widget-presets/filter/filter-bar.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { - createPopup, - type PopupTarget, - popupTargetFromElement, -} from '@blocksuite/affine-components/context-menu'; -import { ShadowlessElement } from '@blocksuite/block-std'; -import { SignalWatcher } from '@blocksuite/global/utils'; -import { CloseIcon, FilterIcon, PlusIcon } from '@blocksuite/icons/lit'; -import { computed, type ReadonlySignal } from '@preact/signals-core'; -import { css, html, type TemplateResult } from 'lit'; -import { property } from 'lit/decorators.js'; -import { repeat } from 'lit/directives/repeat.js'; - -import type { Filter, FilterGroup, Variable } from '../../core/common/ast.js'; - -import { popCreateFilter } from '../../core/common/ref/ref.js'; -import { renderTemplate } from '../../core/utils/uni-component/render-template.js'; -import { popFilterGroup } from './filter-modal.js'; - -export class FilterBar extends SignalWatcher(ShadowlessElement) { - static override styles = css` - microsheet-filter-bar { - margin-top: 8px; - display: flex; - gap: 8px; - } - - .filter-group-tag { - font-size: 12px; - font-style: normal; - font-weight: 600; - line-height: 20px; - display: flex; - align-items: center; - padding: 4px; - background-color: var(--affine-white); - } - - .microsheet-filter-bar-add-filter { - color: var(--affine-text-secondary-color); - padding: 4px 8px; - display: flex; - align-items: center; - gap: 6px; - font-size: 14px; - font-style: normal; - font-weight: 400; - line-height: 22px; - } - `; - - private _setFilter = (index: number, filter: Filter) => { - this.onChange({ - ...this.filterGroup.value, - conditions: this.filterGroup.value.conditions.map((v, i) => - index === i ? filter : v - ), - }); - }; - - private addFilter = (e: MouseEvent) => { - const element = popupTargetFromElement(e.target as HTMLElement); - popCreateFilter(element, { - vars: this.vars, - onSelect: filter => { - const index = this.filterGroup.value.conditions.length; - this.onChange({ - ...this.filterGroup.value, - conditions: [...this.filterGroup.value.conditions, filter], - }); - requestAnimationFrame(() => { - this.expandGroup(element, index); - }); - }, - }); - }; - - private expandGroup = (position: PopupTarget, i: number) => { - if (this.filterGroup.value.conditions[i]?.type !== 'group') { - return; - } - popFilterGroup(position, { - vars: this.vars, - value$: computed(() => { - return this.filterGroup.value.conditions[i] as FilterGroup; - }), - onChange: filter => { - if (filter) { - this._setFilter(i, filter); - } else { - this.deleteFilter(i); - } - }, - }); - }; - - renderAddFilter = () => { - return html`
- ${PlusIcon()} Add filter -
`; - }; - - renderMore = (count: number) => { - const max = this.filterGroup.value.conditions.length; - if (count === max) { - return this.renderAddFilter(); - } - const showMore = (e: MouseEvent) => { - this.showMoreFilter(e, count); - }; - return html`
- ${max - count} More -
`; - }; - - renderMoreFilter = (count: number): TemplateResult => { - return html`
- ${repeat( - this.filterGroup.value.conditions.slice(count), - (_, i) => - html`
- ${this.renderCondition(i + count)} -
` - )} -
- ${this.renderAddFilter()} -
`; - }; - - showMoreFilter = (e: MouseEvent, count: number) => { - const ins = renderTemplate(() => this.renderMoreFilter(count)); - ins.style.position = 'absolute'; - this.updateMoreFilterPanel = () => { - const max = this.filterGroup.value.conditions.length; - if (count === max) { - close(); - this.updateMoreFilterPanel = undefined; - return; - } - ins.requestUpdate(); - }; - const close = createPopup( - popupTargetFromElement(e.target as HTMLElement), - ins, - { - onClose: () => { - this.updateMoreFilterPanel = undefined; - }, - } - ); - }; - - updateMoreFilterPanel?: () => void; - - private deleteFilter(i: number) { - this.onChange({ - ...this.filterGroup.value, - conditions: this.filterGroup.value.conditions.filter( - (_, index) => index !== i - ), - }); - } - - override render() { - return html` - - `; - } - - renderCondition(i: number) { - const condition = this.filterGroup.value.conditions[i]; - const deleteFilter = () => { - this.deleteFilter(i); - }; - if (!condition) { - return; - } - if (condition.type === 'filter') { - return html` `; - } - const expandGroup = (e: MouseEvent) => { - const element = (e.currentTarget as HTMLElement) - .parentElement as HTMLElement; - this.expandGroup(popupTargetFromElement(element), i); - }; - const length = condition.conditions.length; - const text = length > 1 ? `${length} rules` : `${length} rule`; - return html`
-
- ${FilterIcon()} ${text} -
-
- ${CloseIcon()} -
-
`; - } - - renderFilters() { - return this.filterGroup.value.conditions.map( - (_, i) => () => this.renderCondition(i) - ); - } - - override updated() { - this.updateMoreFilterPanel?.(); - } - - @property({ attribute: false }) - accessor filterGroup!: ReadonlySignal; - - @property({ attribute: false }) - accessor onChange!: (filter: FilterGroup) => void; - - @property({ attribute: false }) - accessor vars!: ReadonlySignal; -} - -declare global { - interface HTMLElementTagNameMap { - 'microsheet-filter-bar': FilterBar; - } -} diff --git a/packages/affine/microsheet-data-view/src/widget-presets/filter/filter-group.ts b/packages/affine/microsheet-data-view/src/widget-presets/filter/filter-group.ts deleted file mode 100644 index 2866f8041c01..000000000000 --- a/packages/affine/microsheet-data-view/src/widget-presets/filter/filter-group.ts +++ /dev/null @@ -1,384 +0,0 @@ -import type { TemplateResult } from 'lit'; - -import { - menu, - popFilterableSimpleMenu, - popupTargetFromElement, -} from '@blocksuite/affine-components/context-menu'; -import { ShadowlessElement } from '@blocksuite/block-std'; -import { SignalWatcher } from '@blocksuite/global/utils'; -import { - ArrowDownSmallIcon, - ConvertIcon, - DeleteIcon, - DuplicateIcon, - MoreHorizontalIcon, - PlusIcon, -} from '@blocksuite/icons/lit'; -import { computed, type ReadonlySignal } from '@preact/signals-core'; -import { css, html, nothing } from 'lit'; -import { property, state } from 'lit/decorators.js'; -import { classMap } from 'lit/directives/class-map.js'; -import { repeat } from 'lit/directives/repeat.js'; - -import { - type Filter, - type FilterGroup, - firstFilter, - type Variable, -} from '../../core/common/ast.js'; -import { popAddNewFilter } from './condition.js'; - -export class FilterGroupView extends SignalWatcher(ShadowlessElement) { - static override styles = css` - microsheet-filter-group-view { - border-radius: 4px; - display: flex; - flex-direction: column; - user-select: none; - } - - .filter-group-op { - width: 60px; - display: flex; - justify-content: end; - padding: 4px; - height: 34px; - align-items: center; - font-size: 14px; - font-style: normal; - font-weight: 400; - line-height: 22px; - color: var(--affine-text-primary-color); - } - - .filter-group-op-clickable { - border-radius: 4px; - cursor: pointer; - } - - .filter-group-op-clickable:hover { - background-color: var(--affine-hover-color); - } - - .filter-group-container { - display: flex; - flex-direction: column; - gap: 2px; - } - - .filter-group-button { - padding: 8px 12px; - display: flex; - align-items: center; - gap: 6px; - font-size: 14px; - line-height: 22px; - border-radius: 4px; - cursor: pointer; - color: var(--affine-text-secondary-color); - } - - .filter-group-button svg { - fill: var(--affine-text-secondary-color); - color: var(--affine-text-secondary-color); - width: 20px; - height: 20px; - } - - .filter-group-button:hover { - background-color: var(--affine-hover-color); - color: var(--affine-text-primary-color); - } - - .filter-group-button:hover svg { - fill: var(--affine-text-primary-color); - color: var(--affine-text-primary-color); - } - - .filter-group-item { - padding: 4px 0; - display: flex; - align-items: start; - gap: 8px; - } - - .filter-group-item-ops { - margin-top: 4px; - padding: 4px; - border-radius: 4px; - height: max-content; - display: flex; - cursor: pointer; - } - - .filter-group-item-ops:hover { - background-color: var(--affine-hover-color); - } - - .filter-group-item-ops svg { - fill: var(--affine-text-secondary-color); - color: var(--affine-text-secondary-color); - width: 18px; - height: 18px; - } - - .filter-group-item-ops:hover svg { - fill: var(--affine-text-primary-color); - color: var(--affine-text-primary-color); - } - - .delete-style { - background-color: var(--affine-background-error-color); - } - - .filter-group-border { - border: 1px dashed var(--affine-border-color); - } - - .filter-group-bg-1 { - background-color: var(--affine-background-secondary-color); - border: 1px solid var(--affine-border-color); - } - - .filter-group-bg-2 { - background-color: var(--affine-background-tertiary-color); - border: 1px solid var(--affine-border-color); - } - - .hover-style { - background-color: var(--affine-hover-color); - } - - .delete-style { - background-color: var(--affine-background-error-color); - } - `; - - private _addNew = (e: MouseEvent) => { - if (this.isMaxDepth) { - this.onChange({ - ...this.filterGroup.value, - conditions: [ - ...this.filterGroup.value.conditions, - firstFilter(this.vars.value), - ], - }); - return; - } - popAddNewFilter(popupTargetFromElement(e.currentTarget as HTMLElement), { - value: this.filterGroup.value, - onChange: this.onChange, - vars: this.vars.value, - }); - }; - - private _selectOp = (event: MouseEvent) => { - popFilterableSimpleMenu( - popupTargetFromElement(event.currentTarget as HTMLElement), - [ - menu.action({ - name: 'And', - select: () => { - this.onChange({ - ...this.filterGroup.value, - op: 'and', - }); - }, - }), - menu.action({ - name: 'Or', - select: () => { - this.onChange({ - ...this.filterGroup.value, - op: 'or', - }); - }, - }), - ] - ); - }; - - private _setFilter = (index: number, filter: Filter) => { - this.onChange({ - ...this.filterGroup.value, - conditions: this.filterGroup.value.conditions.map((v, i) => - index === i ? filter : v - ), - }); - }; - - private opMap = { - and: 'And', - or: 'Or', - }; - - private get isMaxDepth() { - return this.depth === 3; - } - - private _clickConditionOps(target: HTMLElement, i: number) { - const filter = this.filterGroup.value.conditions[i]; - popFilterableSimpleMenu(popupTargetFromElement(target), [ - menu.action({ - name: filter.type === 'filter' ? 'Turn into group' : 'Wrap in group', - prefix: ConvertIcon(), - onHover: hover => { - this.containerClass = hover - ? { index: i, class: 'hover-style' } - : undefined; - }, - hide: () => this.depth + getDepth(filter) > 3, - select: () => { - this.onChange({ - type: 'group', - op: 'and', - conditions: [this.filterGroup.value], - }); - }, - }), - menu.action({ - name: 'Duplicate', - prefix: DuplicateIcon(), - onHover: hover => { - this.containerClass = hover - ? { index: i, class: 'hover-style' } - : undefined; - }, - select: () => { - const conditions = [...this.filterGroup.value.conditions]; - conditions.splice( - i + 1, - 0, - JSON.parse(JSON.stringify(conditions[i])) - ); - this.onChange({ ...this.filterGroup.value, conditions: conditions }); - }, - }), - menu.group({ - name: '', - items: [ - menu.action({ - name: 'Delete', - prefix: DeleteIcon(), - class: 'delete-item', - onHover: hover => { - this.containerClass = hover - ? { index: i, class: 'delete-style' } - : undefined; - }, - select: () => { - const conditions = [...this.filterGroup.value.conditions]; - conditions.splice(i, 1); - this.onChange({ - ...this.filterGroup.value, - conditions, - }); - }, - }), - ], - }), - ]); - } - - override render() { - const data = this.filterGroup.value; - return html` -
- ${repeat(data.conditions, (filter, i) => { - const clickOps = (e: MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - this._clickConditionOps(e.target as HTMLElement, i); - }; - let op: TemplateResult; - if (i === 0) { - op = html`
Where
`; - } else { - op = html` -
- ${this.opMap[data.op]} -
- `; - } - const classList = classMap({ - 'filter-root-item': true, - 'filter-exactly-hover-container': true, - 'dv-pd-4 dv-round-4': true, - [this.containerClass?.class ?? '']: - this.containerClass?.index === i, - }); - const groupClassList = classMap({ - [`filter-group-bg-${this.depth}`]: filter.type !== 'filter', - }); - return html`
- ${op} -
- ${filter.type === 'filter' - ? html` - - ` - : html` - - `} -
- ${MoreHorizontalIcon()} -
-
-
`; - })} -
-
- ${PlusIcon()} Add ${this.isMaxDepth ? nothing : ArrowDownSmallIcon()} -
- `; - } - - @state() - accessor containerClass: - | { - index: number; - class: string; - } - | undefined = undefined; - - @property({ attribute: false }) - accessor depth = 1; - - @property({ attribute: false }) - accessor filterGroup!: ReadonlySignal; - - @property({ attribute: false }) - accessor onChange!: (filter: FilterGroup) => void; - - @property({ attribute: false }) - accessor vars!: ReadonlySignal; -} - -declare global { - interface HTMLElementTagNameMap { - 'microsheet-filter-group-view': FilterGroupView; - } -} -export const getDepth = (filter: Filter): number => { - if (filter.type === 'filter') { - return 1; - } - return Math.max(...filter.conditions.map(getDepth)) + 1; -}; diff --git a/packages/affine/microsheet-data-view/src/widget-presets/filter/filter-modal.ts b/packages/affine/microsheet-data-view/src/widget-presets/filter/filter-modal.ts deleted file mode 100644 index 934645e0bc64..000000000000 --- a/packages/affine/microsheet-data-view/src/widget-presets/filter/filter-modal.ts +++ /dev/null @@ -1,115 +0,0 @@ -import type { ReadonlySignal } from '@preact/signals-core'; - -import { - menu, - popMenu, - type PopupTarget, - popupTargetFromElement, -} from '@blocksuite/affine-components/context-menu'; -import { DeleteIcon, PlusIcon } from '@blocksuite/icons/lit'; -import { html } from 'lit'; - -import type { SingleView } from '../../core/index.js'; - -import { - emptyFilterGroup, - type FilterGroup, - type Variable, -} from '../../core/common/ast.js'; -import { popAddNewFilter } from './condition.js'; - -export const popFilterRoot = ( - target: PopupTarget, - props: { - view: SingleView; - onBack: () => void; - } -) => { - popMenu(target, { - options: { - title: { - text: 'Filters', - onBack: props.onBack, - }, - items: [ - menu.group({ - items: [ - () => { - const view = props.view; - const onChange = view.filterSet.bind(view); - return html` `; - }, - ], - }), - menu.group({ - items: [ - menu.action({ - name: 'Add', - prefix: PlusIcon(), - select: ele => { - const view = props.view; - const vars = view.vars$.value; - const value = view.filter$.value ?? emptyFilterGroup; - const onChange = view.filterSet.bind(view); - popAddNewFilter(popupTargetFromElement(ele), { - value: value, - onChange: onChange, - vars: vars, - }); - return false; - }, - }), - ], - }), - ], - }, - }); -}; -export const popFilterGroup = ( - target: PopupTarget, - props: { - vars: ReadonlySignal; - value$: ReadonlySignal; - onChange: (value?: FilterGroup) => void; - onBack?: () => void; - } -) => { - popMenu(target, { - options: { - title: { - text: 'Filter group', - onBack: props.onBack, - }, - items: [ - menu.group({ - items: [ - () => { - return html` `; - }, - ], - }), - menu.group({ - items: [ - menu.action({ - name: 'Delete', - class: 'delete-item', - prefix: DeleteIcon(), - select: () => { - props.onChange(); - }, - }), - ], - }), - ], - }, - }); -}; diff --git a/packages/affine/microsheet-data-view/src/widget-presets/filter/filter-root.ts b/packages/affine/microsheet-data-view/src/widget-presets/filter/filter-root.ts deleted file mode 100644 index 795f0d819baa..000000000000 --- a/packages/affine/microsheet-data-view/src/widget-presets/filter/filter-root.ts +++ /dev/null @@ -1,318 +0,0 @@ -import { - menu, - popFilterableSimpleMenu, - popupTargetFromElement, -} from '@blocksuite/affine-components/context-menu'; -import { ShadowlessElement } from '@blocksuite/block-std'; -import { SignalWatcher } from '@blocksuite/global/utils'; -import { - ConvertIcon, - DeleteIcon, - DuplicateIcon, - MoreHorizontalIcon, -} from '@blocksuite/icons/lit'; -import { computed, type ReadonlySignal } from '@preact/signals-core'; -import { css, html, nothing } from 'lit'; -import { property, state } from 'lit/decorators.js'; -import { classMap } from 'lit/directives/class-map.js'; -import { repeat } from 'lit/directives/repeat.js'; - -import type { Filter, FilterGroup, Variable } from '../../core/common/ast.js'; -import type { FilterGroupView } from './filter-group.js'; - -import { getDepth } from './filter-group.js'; - -export class FilterRootView extends SignalWatcher(ShadowlessElement) { - static override styles = css` - .filter-root-title { - padding: 12px; - font-size: 14px; - font-weight: 600; - line-height: 22px; - color: var(--affine-text-primary-color); - } - - .filter-root-op { - width: 60px; - display: flex; - justify-content: end; - padding: 4px; - height: 34px; - align-items: center; - } - - .filter-root-op-clickable { - border-radius: 4px; - cursor: pointer; - } - - .filter-root-op-clickable:hover { - background-color: var(--affine-hover-color); - } - - .filter-root-container { - display: flex; - flex-direction: column; - gap: 4px; - max-height: 400px; - overflow: auto; - } - - .filter-root-button { - margin: 4px 8px 8px; - padding: 8px 12px; - display: flex; - align-items: center; - gap: 6px; - font-size: 14px; - line-height: 22px; - border-radius: 4px; - cursor: pointer; - color: var(--affine-text-secondary-color); - } - - .filter-root-button svg { - fill: var(--affine-text-secondary-color); - color: var(--affine-text-secondary-color); - width: 20px; - height: 20px; - } - - .filter-root-button:hover { - background-color: var(--affine-hover-color); - color: var(--affine-text-primary-color); - } - .filter-root-button:hover svg { - fill: var(--affine-text-primary-color); - color: var(--affine-text-primary-color); - } - - .filter-root-item { - padding: 4px 0; - display: flex; - align-items: start; - gap: 8px; - } - - .filter-group-title { - font-size: 14px; - font-style: normal; - font-weight: 500; - line-height: 22px; - display: flex; - align-items: center; - color: var(--affine-text-primary-color); - gap: 6px; - } - - .filter-root-item-ops { - margin-top: 2px; - padding: 4px; - border-radius: 4px; - height: max-content; - display: flex; - cursor: pointer; - } - - .filter-root-item-ops:hover { - background-color: var(--affine-hover-color); - } - - .filter-root-item-ops svg { - fill: var(--affine-text-secondary-color); - color: var(--affine-text-secondary-color); - width: 18px; - height: 18px; - } - .filter-root-item-ops:hover svg { - fill: var(--affine-text-primary-color); - color: var(--affine-text-primary-color); - } - - .filter-root-grabber { - cursor: grab; - width: 4px; - height: 12px; - background-color: var(--affine-placeholder-color); - border-radius: 1px; - } - - .divider { - height: 1px; - background-color: var(--affine-divider-color); - flex-shrink: 0; - margin: 8px 0; - } - `; - - private _setFilter = (index: number, filter: Filter) => { - this.onChange({ - ...this.filterGroup.value, - conditions: this.filterGroup.value.conditions.map((v, i) => - index === i ? filter : v - ), - }); - }; - - private _clickConditionOps(target: HTMLElement, i: number) { - const filter = this.filterGroup.value.conditions[i]; - popFilterableSimpleMenu(popupTargetFromElement(target), [ - menu.action({ - name: filter.type === 'filter' ? 'Turn into group' : 'Wrap in group', - prefix: ConvertIcon(), - onHover: hover => { - this.containerClass = hover - ? { index: i, class: 'hover-style' } - : undefined; - }, - hide: () => getDepth(filter) > 3, - select: () => { - this.onChange({ - type: 'group', - op: 'and', - conditions: [this.filterGroup.value], - }); - }, - }), - menu.action({ - name: 'Duplicate', - prefix: DuplicateIcon(), - onHover: hover => { - this.containerClass = hover - ? { index: i, class: 'hover-style' } - : undefined; - }, - select: () => { - const conditions = [...this.filterGroup.value.conditions]; - conditions.splice( - i + 1, - 0, - JSON.parse(JSON.stringify(conditions[i])) - ); - this.onChange({ ...this.filterGroup.value, conditions: conditions }); - }, - }), - menu.group({ - name: '', - items: [ - menu.action({ - name: 'Delete', - prefix: DeleteIcon(), - class: 'delete-item', - onHover: hover => { - this.containerClass = hover - ? { index: i, class: 'delete-style' } - : undefined; - }, - select: () => { - const conditions = [...this.filterGroup.value.conditions]; - conditions.splice(i, 1); - this.onChange({ - ...this.filterGroup.value, - conditions, - }); - }, - }), - ], - }), - ]); - } - - override render() { - const data = this.filterGroup.value; - return html` -
- ${repeat(data.conditions, (filter, i) => { - const clickOps = (e: MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - this._clickConditionOps(e.target as HTMLElement, i); - }; - const ops = html` -
- ${MoreHorizontalIcon()} -
- `; - const content = - filter.type === 'filter' - ? html` -
-
-
- -
- ${ops} -
- ` - : html` -
-
-
-
- Filter group -
- ${ops} -
-
- -
-
- `; - const classList = classMap({ - 'filter-root-item': true, - 'filter-exactly-hover-container': true, - 'dv-pd-4 dv-round-4': true, - [this.containerClass?.class ?? '']: - this.containerClass?.index === i, - }); - return html` ${data.conditions[i - 1]?.type === 'group' || - filter.type === 'group' - ? html`
` - : nothing} -
- ${content} -
`; - })} -
- `; - } - - @state() - accessor containerClass: - | { - index: number; - class: string; - } - | undefined = undefined; - - @property({ attribute: false }) - accessor filterGroup!: ReadonlySignal; - - @property({ attribute: false }) - accessor onBack!: () => void; - - @property({ attribute: false }) - accessor onChange!: (filter: FilterGroup) => void; - - @property({ attribute: false }) - accessor vars!: ReadonlySignal; -} - -declare global { - interface HTMLElementTagNameMap { - 'microsheet-filter-root-view': FilterGroupView; - } -} diff --git a/packages/affine/microsheet-data-view/src/widget-presets/filter/index.ts b/packages/affine/microsheet-data-view/src/widget-presets/filter/index.ts deleted file mode 100644 index 1efeb48deff4..000000000000 --- a/packages/affine/microsheet-data-view/src/widget-presets/filter/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { html } from 'lit'; - -import type { DataViewWidgetProps } from '../../core/widget/types.js'; - -import { defineUniComponent } from '../../core/index.js'; -import { ShowFilterContextKey } from './context.js'; - -export const widgetFilterBar = defineUniComponent( - (props: DataViewWidgetProps) => { - const view = props.view; - if ( - view.filter$.value.conditions.length <= 0 || - !view.contextGet(ShowFilterContextKey).value[view.id] - ) { - return html``; - } - return html` `; - } -); diff --git a/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/boolean.ts b/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/boolean.ts deleted file mode 100644 index 45b7c70eeb27..000000000000 --- a/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/boolean.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { FilterDefineType } from './matcher.js'; - -import { tBoolean } from '../../../core/logical/data-type.js'; -import { tFunction } from '../../../core/logical/typesystem.js'; - -export const booleanFilter = { - isChecked: { - type: tFunction({ args: [tBoolean.create()], rt: tBoolean.create() }), - label: 'Is checked', - impl: value => { - return !!value; - }, - }, - isUnchecked: { - type: tFunction({ args: [tBoolean.create()], rt: tBoolean.create() }), - label: 'Is unchecked', - impl: value => { - return !value; - }, - }, -} satisfies Record; diff --git a/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/date.ts b/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/date.ts deleted file mode 100644 index 4358ea24f084..000000000000 --- a/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/date.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { FilterDefineType } from './matcher.js'; - -import { tBoolean, tDate } from '../../../core/logical/data-type.js'; -import { tFunction } from '../../../core/logical/typesystem.js'; - -export const dateFilter = { - before: { - type: tFunction({ - args: [tDate.create(), tDate.create()], - rt: tBoolean.create(), - }), - label: 'Before', - impl: (value, target) => { - if (typeof value !== 'number' || typeof target !== 'number') { - return true; - } - return value < target; - }, - }, - after: { - type: tFunction({ - args: [tDate.create(), tDate.create()], - rt: tBoolean.create(), - }), - label: 'After', - impl: (value, target) => { - if (typeof value !== 'number' || typeof target !== 'number') { - return true; - } - return value > target; - }, - }, -} as Record; diff --git a/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/matcher.ts b/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/matcher.ts deleted file mode 100644 index 7121d615d878..000000000000 --- a/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/matcher.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Matcher, MatcherCreator } from '../../../core/logical/matcher.js'; -import { - type TFunction, - typesystem, -} from '../../../core/logical/typesystem.js'; -import { booleanFilter } from './boolean.js'; -import { dateFilter } from './date.js'; -import { multiTagFilter } from './multi-tag.js'; -import { numberFilter } from './number.js'; -import { stringFilter } from './string.js'; -import { tagFilter } from './tag.js'; -import { unknownFilter } from './unknown.js'; - -export type FilterMatcherDataType = { - name: string; - label: string; - impl: (...args: unknown[]) => boolean; -}; -export type FilterDefineType = { - type: TFunction; -} & Omit; -const allFilter = { - ...dateFilter, - ...multiTagFilter, - ...numberFilter, - ...stringFilter, - ...tagFilter, - ...booleanFilter, - ...unknownFilter, -}; -const filterMatcherCreator = new MatcherCreator< - FilterMatcherDataType, - TFunction ->(); -const filterMatchers = Object.entries(allFilter).map( - ([name, { type, ...data }]) => { - return filterMatcherCreator.createMatcher(type, { - name: name, - ...data, - }); - } -); -export const filterMatcher = new Matcher( - filterMatchers, - (type, target) => { - if (type.type !== 'function') { - return false; - } - const staticType = typesystem.subst( - Object.fromEntries(type.typeVars?.map(v => [v.name, v.bound]) ?? []), - type - ); - const firstArg = staticType.args[0]; - return firstArg && typesystem.isSubtype(firstArg, target); - } -); diff --git a/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/multi-tag.ts b/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/multi-tag.ts deleted file mode 100644 index fd1551f71676..000000000000 --- a/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/multi-tag.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { FilterDefineType } from './matcher.js'; - -import { tBoolean, tTag } from '../../../core/logical/data-type.js'; -import { - tArray, - tFunction, - tTypeRef, - tTypeVar, -} from '../../../core/logical/typesystem.js'; - -export const multiTagFilter = { - containsAll: { - type: tFunction({ - typeVars: [tTypeVar('options', tTag.create())], - args: [tArray(tTypeRef('options')), tArray(tTypeRef('options'))], - rt: tBoolean.create(), - }), - label: 'Contains all', - impl: (value, target) => { - if (!Array.isArray(target) || !Array.isArray(value) || !target.length) { - return true; - } - return target.every(v => value.includes(v)); - }, - }, - containsOneOf: { - type: tFunction({ - typeVars: [tTypeVar('options', tTag.create())], - args: [tArray(tTypeRef('options')), tArray(tTypeRef('options'))], - rt: tBoolean.create(), - }), - name: 'containsOneOf', - label: 'Contains one of', - impl: (value, target) => { - if (!Array.isArray(target) || !Array.isArray(value) || !target.length) { - return true; - } - return target.some(v => value.includes(v)); - }, - }, - doesNotContainsOneOf: { - type: tFunction({ - typeVars: [tTypeVar('options', tTag.create())], - args: [tArray(tTypeRef('options')), tArray(tTypeRef('options'))], - rt: tBoolean.create(), - }), - label: 'Does not contains one of', - impl: (value, target) => { - if (!Array.isArray(target) || !Array.isArray(value) || !target.length) { - return true; - } - return target.every(v => !value.includes(v)); - }, - }, - doesNotContainsAll: { - type: tFunction({ - typeVars: [tTypeVar('options', tTag.create())], - args: [tArray(tTypeRef('options')), tArray(tTypeRef('options'))], - rt: tBoolean.create(), - }), - label: 'Does not contains all', - impl: (value, target) => { - if (!Array.isArray(target) || !Array.isArray(value) || !target.length) { - return true; - } - return !target.every(v => value.includes(v)); - }, - }, -} as Record; diff --git a/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/number.ts b/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/number.ts deleted file mode 100644 index 8a2e783d59f7..000000000000 --- a/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/number.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { FilterDefineType } from './matcher.js'; - -import { tBoolean, tNumber } from '../../../core/logical/data-type.js'; -import { tFunction } from '../../../core/logical/typesystem.js'; - -export const numberFilter = { - greatThan: { - type: tFunction({ - args: [tNumber.create(), tNumber.create()], - rt: tBoolean.create(), - }), - label: '>', - impl: (value, target) => { - value = value ?? 0; - if (typeof value !== 'number' || typeof target !== 'number') { - return true; - } - return value > target; - }, - }, - greatThanOrEqual: { - type: tFunction({ - args: [tNumber.create(), tNumber.create()], - rt: tBoolean.create(), - }), - label: '>=', - impl: (value, target) => { - value = value ?? 0; - if (typeof value !== 'number' || typeof target !== 'number') { - return true; - } - return value >= target; - }, - }, - lessThan: { - type: tFunction({ - args: [tNumber.create(), tNumber.create()], - rt: tBoolean.create(), - }), - label: '<', - impl: (value, target) => { - value = value ?? 0; - if (typeof value !== 'number' || typeof target !== 'number') { - return true; - } - return value < target; - }, - }, - lessThanOrEqual: { - type: tFunction({ - args: [tNumber.create(), tNumber.create()], - rt: tBoolean.create(), - }), - label: '<=', - impl: (value, target) => { - value = value ?? 0; - if (typeof value !== 'number' || typeof target !== 'number') { - return true; - } - return value <= target; - }, - }, - equal: { - type: tFunction({ - args: [tNumber.create(), tNumber.create()], - rt: tBoolean.create(), - }), - label: '==', - impl: (value, target) => { - value = value ?? 0; - if (typeof value !== 'number' || typeof target !== 'number') { - return true; - } - return value == target; - }, - }, - notEqual: { - type: tFunction({ - args: [tNumber.create(), tNumber.create()], - rt: tBoolean.create(), - }), - label: '!=', - impl: (value, target) => { - value = value ?? 0; - if (typeof value !== 'number' || typeof target !== 'number') { - return true; - } - return value != target; - }, - }, -} as Record; diff --git a/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/string.ts b/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/string.ts deleted file mode 100644 index 342558de6696..000000000000 --- a/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/string.ts +++ /dev/null @@ -1,109 +0,0 @@ -import type { FilterDefineType } from './matcher.js'; - -import { tBoolean, tString } from '../../../core/logical/data-type.js'; -import { tFunction } from '../../../core/logical/typesystem.js'; - -export const stringFilter = { - is: { - type: tFunction({ - args: [tString.create(), tString.create()], - rt: tBoolean.create(), - }), - label: 'Is', - impl: (value, target) => { - if ( - typeof value !== 'string' || - typeof target !== 'string' || - target === '' - ) { - return true; - } - return value == target; - }, - }, - isNot: { - type: tFunction({ - args: [tString.create(), tString.create()], - rt: tBoolean.create(), - }), - label: 'Is not', - impl: (value, target) => { - if ( - typeof value !== 'string' || - typeof target !== 'string' || - target === '' - ) { - return true; - } - return value != target; - }, - }, - contains: { - type: tFunction({ - args: [tString.create(), tString.create()], - rt: tBoolean.create(), - }), - label: 'Contains', - impl: (value, target) => { - if ( - typeof value !== 'string' || - typeof target !== 'string' || - target === '' - ) { - return true; - } - return value.includes(target); - }, - }, - doesNoContains: { - type: tFunction({ - args: [tString.create(), tString.create()], - rt: tBoolean.create(), - }), - label: 'Does no contains', - impl: (value, target) => { - if ( - typeof value !== 'string' || - typeof target !== 'string' || - target === '' - ) { - return true; - } - return !value.includes(target); - }, - }, - startsWith: { - type: tFunction({ - args: [tString.create(), tString.create()], - rt: tBoolean.create(), - }), - label: 'Starts with', - impl: (value, target) => { - if ( - typeof value !== 'string' || - typeof target !== 'string' || - target === '' - ) { - return true; - } - return value.startsWith(target); - }, - }, - endsWith: { - type: tFunction({ - args: [tString.create(), tString.create()], - rt: tBoolean.create(), - }), - label: 'Ends with', - impl: (value, target) => { - if ( - typeof value !== 'string' || - typeof target !== 'string' || - target === '' - ) { - return true; - } - return value.endsWith(target); - }, - }, -} as Record; diff --git a/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/tag.ts b/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/tag.ts deleted file mode 100644 index 3677d9471c33..000000000000 --- a/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/tag.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { FilterDefineType } from './matcher.js'; - -import { tBoolean, tTag } from '../../../core/logical/data-type.js'; -import { - tArray, - tFunction, - tTypeRef, - tTypeVar, -} from '../../../core/logical/typesystem.js'; - -export const tagFilter = { - isOneOf: { - type: tFunction({ - typeVars: [tTypeVar('options', tTag.create())], - args: [tTypeRef('options'), tArray(tTypeRef('options'))], - rt: tBoolean.create(), - }), - label: 'Is one of', - impl: (value, target) => { - if (!Array.isArray(target) || !target.length) { - return true; - } - return target.includes(value); - }, - }, - isNotOneOf: { - type: tFunction({ - typeVars: [tTypeVar('options', tTag.create())], - args: [tTypeRef('options'), tArray(tTypeRef('options'))], - rt: tBoolean.create(), - }), - label: 'Is not one of', - impl: (value, target) => { - if (!Array.isArray(target) || !target.length) { - return true; - } - return !target.includes(value); - }, - }, -} as Record; diff --git a/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/unknown.ts b/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/unknown.ts deleted file mode 100644 index 4b0893bb45a0..000000000000 --- a/packages/affine/microsheet-data-view/src/widget-presets/filter/matcher/unknown.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { FilterDefineType } from './matcher.js'; - -import { tBoolean } from '../../../core/logical/data-type.js'; -import { tFunction, tUnknown } from '../../../core/logical/typesystem.js'; - -export const unknownFilter = { - isNotEmpty: { - type: tFunction({ args: [tUnknown.create()], rt: tBoolean.create() }), - label: 'Is not empty', - impl: value => { - if (Array.isArray(value)) { - return value.length > 0; - } - if (typeof value === 'string') { - return !!value; - } - return value != null; - }, - }, - isEmpty: { - type: tFunction({ args: [tUnknown.create()], rt: tBoolean.create() }), - label: 'Is empty', - impl: value => { - if (Array.isArray(value)) { - return value.length === 0; - } - if (typeof value === 'string') { - return !value; - } - return value == null; - }, - }, -} satisfies Record; diff --git a/packages/affine/microsheet-data-view/src/widget-presets/index.ts b/packages/affine/microsheet-data-view/src/widget-presets/index.ts index c3fe5954fb5f..ca54b0d46056 100644 --- a/packages/affine/microsheet-data-view/src/widget-presets/index.ts +++ b/packages/affine/microsheet-data-view/src/widget-presets/index.ts @@ -1,10 +1,7 @@ -import { widgetFilterBar } from './filter/index.js'; import { createWidgetTools, toolsWidgetPresets } from './tools/index.js'; -import { widgetViewsBar } from './views-bar/index.js'; export const widgetPresets = { - viewBar: widgetViewsBar, - filterBar: widgetFilterBar, + viewBar: null, createTools: createWidgetTools, tools: toolsWidgetPresets, }; diff --git a/packages/affine/microsheet-data-view/src/widget-presets/tools/index.ts b/packages/affine/microsheet-data-view/src/widget-presets/tools/index.ts index 190345055d93..5cbdde7d788f 100644 --- a/packages/affine/microsheet-data-view/src/widget-presets/tools/index.ts +++ b/packages/affine/microsheet-data-view/src/widget-presets/tools/index.ts @@ -1,30 +1,19 @@ import type { - DataViewWidget, - DataViewWidgetProps, + MicrosheetDataViewWidget, + MicrosheetDataViewWidgetProps, } from '../../core/widget/types.js'; import { createUniComponentFromWebComponent } from '../../core/index.js'; import { uniMap } from '../../core/utils/uni-component/operation.js'; -import { DataViewHeaderToolsFilter } from './presets/filter/filter.js'; -import { DataViewHeaderToolsSearch } from './presets/search/search.js'; -import { DataViewHeaderToolsAddRow } from './presets/table-add-row/add-row.js'; -import { DataViewHeaderToolsViewOptions } from './presets/view-options/view-options.js'; import { DataViewHeaderTools } from './tools-renderer.js'; -export const toolsWidgetPresets = { - filter: createUniComponentFromWebComponent(DataViewHeaderToolsFilter), - search: createUniComponentFromWebComponent(DataViewHeaderToolsSearch), - viewOptions: createUniComponentFromWebComponent( - DataViewHeaderToolsViewOptions - ), - tableAddRow: createUniComponentFromWebComponent(DataViewHeaderToolsAddRow), -}; +export const toolsWidgetPresets = {}; export const createWidgetTools = ( - toolsMap: Record + toolsMap: Record ) => { return uniMap( createUniComponentFromWebComponent(DataViewHeaderTools), - (props: DataViewWidgetProps) => ({ + (props: MicrosheetDataViewWidgetProps) => ({ ...props, toolsMap, }) diff --git a/packages/affine/microsheet-data-view/src/widget-presets/tools/presets/filter/filter.ts b/packages/affine/microsheet-data-view/src/widget-presets/tools/presets/filter/filter.ts deleted file mode 100644 index 8e1e163c11e4..000000000000 --- a/packages/affine/microsheet-data-view/src/widget-presets/tools/presets/filter/filter.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { popupTargetFromElement } from '@blocksuite/affine-components/context-menu'; -import { FilterIcon } from '@blocksuite/icons/lit'; -import { computed } from '@preact/signals-core'; -import { cssVarV2 } from '@toeverything/theme/v2'; -import { css, html, nothing } from 'lit'; -import { styleMap } from 'lit/directives/style-map.js'; - -import { - emptyFilterGroup, - type FilterGroup, -} from '../../../../core/common/ast.js'; -import { popCreateFilter } from '../../../../core/common/ref/ref.js'; -import { WidgetBase } from '../../../../core/widget/widget-base.js'; -import { ShowFilterContextKey } from '../../../filter/context.js'; - -const styles = css` - .affine-microsheet-filter-button { - display: flex; - align-items: center; - gap: 6px; - line-height: 20px; - padding: 2px; - border-radius: 4px; - cursor: pointer; - font-size: 20px; - } - - .affine-microsheet-filter-button:hover { - background-color: var(--affine-hover-color); - } - - .affine-microsheet-filter-button { - } -`; - -export class DataViewHeaderToolsFilter extends WidgetBase { - static override styles = styles; - - hasFilter = computed(() => { - return this.view.filter$.value.conditions.length > 0; - }); - - private get _filter(): FilterGroup { - return this.view.filter$.value ?? emptyFilterGroup; - } - - private set _filter(filter: FilterGroup) { - this.view.filterSet(filter); - } - - private get readonly() { - return this.view.readonly$.value; - } - - private clickFilter(event: MouseEvent) { - if (this.hasFilter.value) { - this.toggleShowFilter(); - return; - } - this.showToolBar(true); - popCreateFilter( - popupTargetFromElement(event.currentTarget as HTMLElement), - { - vars: this.view.vars$, - onSelect: filter => { - this._filter = { - ...this._filter, - conditions: [filter], - }; - this.toggleShowFilter(true); - }, - onClose: () => { - this.showToolBar(false); - }, - } - ); - return; - } - - override render() { - if (this.readonly) return nothing; - const style = styleMap({ - color: this.hasFilter.value - ? cssVarV2('text/emphasis') - : cssVarV2('icon/primary'), - }); - return html`
- ${FilterIcon()} -
`; - } - - showToolBar(show: boolean) { - const tools = this.closest('data-view-header-tools'); - if (tools) { - tools.showToolBar = show; - } - } - - toggleShowFilter(show?: boolean) { - const map = this.view.contextGet(ShowFilterContextKey); - map.value = { - ...map.value, - [this.view.id]: show ?? !map.value[this.view.id], - }; - } -} - -declare global { - interface HTMLElementTagNameMap { - 'data-view-header-tools-filter': DataViewHeaderToolsFilter; - } -} diff --git a/packages/affine/microsheet-data-view/src/widget-presets/tools/presets/search/search.ts b/packages/affine/microsheet-data-view/src/widget-presets/tools/presets/search/search.ts deleted file mode 100644 index 4796090bd659..000000000000 --- a/packages/affine/microsheet-data-view/src/widget-presets/tools/presets/search/search.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { CloseIcon, SearchIcon } from '@blocksuite/icons/lit'; -import { baseTheme } from '@toeverything/theme'; -import { css, html, unsafeCSS } from 'lit'; -import { query, state } from 'lit/decorators.js'; -import { classMap } from 'lit/directives/class-map.js'; -import { styleMap } from 'lit/directives/style-map.js'; - -import type { TableSingleView } from '../../../../view-presets/table/table-view-manager.js'; - -import { stopPropagation } from '../../../../core/utils/event.js'; -import { WidgetBase } from '../../../../core/widget/widget-base.js'; - -const styles = css` - .affine-microsheet-search-container { - position: relative; - display: flex; - align-items: center; - gap: 8px; - width: 24px; - height: 32px; - border-radius: 8px; - transition: width 0.3s ease; - overflow: hidden; - } - .affine-microsheet-search-container svg { - width: 20px; - height: 20px; - fill: var(--affine-icon-color); - } - - .search-container-expand { - overflow: visible; - width: 138px; - background-color: var(--affine-hover-color); - } - - .search-input-container { - display: flex; - align-items: center; - } - - .close-icon { - display: flex; - align-items: center; - padding-right: 8px; - height: 100%; - cursor: pointer; - } - - .affine-microsheet-search-input-icon { - position: absolute; - left: 0; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - cursor: pointer; - padding: 2px; - border-radius: 4px; - } - .affine-microsheet-search-input-icon:hover { - background: var(--affine-hover-color); - } - - .search-container-expand .affine-microsheet-search-input-icon { - left: 4px; - pointer-events: none; - } - - .affine-microsheet-search-input { - flex: 1; - width: 100%; - padding: 0 2px 0 30px; - border: none; - font-family: ${unsafeCSS(baseTheme.fontSansFamily)}; - font-size: var(--affine-font-sm); - box-sizing: border-box; - color: inherit; - background: transparent; - outline: none; - } - - .affine-microsheet-search-input::placeholder { - color: var(--affine-placeholder-color); - font-size: var(--affine-font-sm); - } -`; - -export class DataViewHeaderToolsSearch extends WidgetBase { - static override styles = styles; - - private _clearSearch = () => { - this._searchInput.value = ''; - this.view.setSearch(''); - this.preventBlur = true; - setTimeout(() => { - this.preventBlur = false; - }); - }; - - private _clickSearch = (e: MouseEvent) => { - e.stopPropagation(); - this.showSearch = true; - }; - - private _onSearch = (event: InputEvent) => { - const el = event.target as HTMLInputElement; - const inputValue = el.value.trim(); - this.view.setSearch(inputValue); - }; - - private _onSearchBlur = () => { - if (this._searchInput.value || this.preventBlur) { - return; - } - this.showSearch = false; - }; - - private _onSearchKeydown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - if (this._searchInput.value) { - this._searchInput.value = ''; - this.view.setSearch(''); - } else { - this.showSearch = false; - } - } - }; - - preventBlur = false; - - get showSearch(): boolean { - return this._showSearch; - } - - set showSearch(value: boolean) { - this._showSearch = value; - const tools = this.closest('data-view-header-tools'); - if (tools) { - tools.showToolBar = value; - } - } - - override render() { - const searchToolClassMap = classMap({ - 'affine-microsheet-search-container': true, - 'search-container-expand': this.showSearch, - }); - return html` - - `; - } - - @query('.affine-microsheet-search-input') - private accessor _searchInput!: HTMLInputElement; - - @state() - private accessor _showSearch = false; - - public override accessor view!: TableSingleView; -} - -declare global { - interface HTMLElementTagNameMap { - 'data-view-header-tools-search': DataViewHeaderToolsSearch; - } -} diff --git a/packages/affine/microsheet-data-view/src/widget-presets/tools/presets/table-add-row/add-row.ts b/packages/affine/microsheet-data-view/src/widget-presets/tools/presets/table-add-row/add-row.ts deleted file mode 100644 index 1627518fdc65..000000000000 --- a/packages/affine/microsheet-data-view/src/widget-presets/tools/presets/table-add-row/add-row.ts +++ /dev/null @@ -1,214 +0,0 @@ -import type { InsertToPosition } from '@blocksuite/affine-shared/utils'; - -import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; -import { PlusIcon } from '@blocksuite/icons/lit'; -import { css, html } from 'lit'; -import { state } from 'lit/decorators.js'; - -import { startDrag } from '../../../../core/utils/drag.js'; -import { WidgetBase } from '../../../../core/widget/widget-base.js'; -import { NewRecordPreview } from './new-record-preview.js'; - -const styles = css` - .affine-microsheet-toolbar-item.new-record { - margin-left: 12px; - display: flex; - align-items: center; - gap: 4px; - height: 32px; - padding: 4px 8px 4px 4px; - border-radius: 4px; - background: var(--affine-white); - cursor: grab; - font-size: 15px; - font-weight: 500; - line-height: 24px; - color: ${unsafeCSSVarV2('text/primary')}; - border: 1px solid ${unsafeCSSVarV2('layer/insideBorder/blackBorder')}; - } - - .new-record svg { - font-size: 20px; - color: ${unsafeCSSVarV2('icon/primary')}; - } -`; - -export class DataViewHeaderToolsAddRow extends WidgetBase { - static override styles = styles; - - private _onAddNewRecord = () => { - if (this.readonly) return; - const selection = this.viewMethods.getSelection?.(); - if (!selection) { - this.addRow('start'); - } else if ( - selection.type === 'table' && - selection.selectionType === 'area' - ) { - const { rowsSelection, columnsSelection, focus } = selection; - let index = 0; - if (rowsSelection && !columnsSelection) { - // rows - index = rowsSelection.end; - } else if (rowsSelection && columnsSelection) { - // multiple cells - index = rowsSelection.end; - } else if (!rowsSelection && !columnsSelection && focus) { - // single cell - index = focus.rowIndex; - } - - this.addRow(index + 1); - } - }; - - _dragStart = (e: MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - const container = this.closest('affine-microsheet-data-view-renderer'); - const tableRect = container - ?.querySelector('affine-microsheet-table') - ?.getBoundingClientRect(); - const rows: NodeListOf | undefined = - container?.querySelectorAll('.affine-microsheet-block-row'); - if (!rows || !tableRect) { - return; - } - const rects = Array.from(rows).map(v => { - const rect = v.getBoundingClientRect(); - return { - id: v.dataset.rowId as string, - top: rect.top, - bottom: rect.bottom, - mid: (rect.top + rect.bottom) / 2, - width: rect.width, - left: rect.left, - }; - }); - - const getPosition = ( - y: number - ): - | { position: InsertToPosition; y: number; x: number; width: number } - | undefined => { - const data = rects.find(v => y < v.bottom); - if (!data || y < data.top) { - return; - } - return { - position: { - id: data.id, - before: y < data.mid, - }, - y: y < data.mid ? data.top : data.bottom, - width: data.width, - x: data.left, - }; - }; - - const dropPreview = createDropPreview(); - const dragPreview = createDragPreview(); - startDrag<{ position?: InsertToPosition }, MouseEvent>(e, { - transform: e => e, - onDrag: () => { - return {}; - }, - onMove: e => { - dragPreview.display(e.x, e.y); - const p = getPosition(e.y); - if (p) { - dropPreview.display(tableRect.left, p.y, tableRect.width); - } else { - dropPreview.remove(); - } - return { - position: p?.position, - }; - }, - onDrop: data => { - if (data.position) { - this.viewMethods.addRow?.(data.position); - } - }, - onClear: () => { - dropPreview.remove(); - dragPreview.remove(); - }, - }); - }; - - addRow = (position: InsertToPosition | number) => { - this.viewMethods.addRow?.(position); - }; - - private get readonly() { - return this.view.readonly$.value; - } - - override connectedCallback() { - super.connectedCallback(); - if (!this.readonly) { - this.disposables.addFromEvent(this, 'pointerdown', e => { - this._dragStart(e); - }); - } - } - - override render() { - if (this.readonly) { - return; - } - return html`
- ${PlusIcon()}New Record -
`; - } - - @state() - accessor showToolBar = false; -} - -declare global { - interface HTMLElementTagNameMap { - 'data-view-header-tools-add-row': DataViewHeaderToolsAddRow; - } -} -const createDropPreview = () => { - const div = document.createElement('div'); - div.dataset.isDropPreview = 'true'; - div.style.pointerEvents = 'none'; - div.style.position = 'fixed'; - div.style.zIndex = '9999'; - div.style.height = '4px'; - div.style.borderRadius = '2px'; - div.style.backgroundColor = 'var(--affine-primary-color)'; - div.style.boxShadow = '0px 0px 8px 0px rgba(30, 150, 235, 0.35)'; - return { - display(x: number, y: number, width: number) { - document.body.append(div); - div.style.left = `${x}px`; - div.style.top = `${y - 2}px`; - div.style.width = `${width}px`; - }, - remove() { - div.remove(); - }, - }; -}; - -const createDragPreview = () => { - const preview = new NewRecordPreview(); - document.body.append(preview); - return { - display(x: number, y: number) { - preview.style.left = `${x}px`; - preview.style.top = `${y}px`; - }, - remove() { - preview.remove(); - }, - }; -}; diff --git a/packages/affine/microsheet-data-view/src/widget-presets/tools/presets/table-add-row/new-record-preview.ts b/packages/affine/microsheet-data-view/src/widget-presets/tools/presets/table-add-row/new-record-preview.ts deleted file mode 100644 index 2bc4a7990b46..000000000000 --- a/packages/affine/microsheet-data-view/src/widget-presets/tools/presets/table-add-row/new-record-preview.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { ShadowlessElement } from '@blocksuite/block-std'; -import { PlusIcon } from '@blocksuite/icons/lit'; -import { html } from 'lit'; - -export class NewRecordPreview extends ShadowlessElement { - override render() { - return html` - - ${PlusIcon()} - `; - } -} diff --git a/packages/affine/microsheet-data-view/src/widget-presets/tools/presets/view-options/view-options.ts b/packages/affine/microsheet-data-view/src/widget-presets/tools/presets/view-options/view-options.ts deleted file mode 100644 index cdc0e7176845..000000000000 --- a/packages/affine/microsheet-data-view/src/widget-presets/tools/presets/view-options/view-options.ts +++ /dev/null @@ -1,307 +0,0 @@ -import { - menu, - type MenuButtonData, - type MenuConfig, - popMenu, - type PopupTarget, - popupTargetFromElement, -} from '@blocksuite/affine-components/context-menu'; -import { - ArrowRightSmallIcon, - DeleteIcon, - DuplicateIcon, - FilterIcon, - GroupingIcon, - InfoIcon, - LayoutIcon, - MoreHorizontalIcon, -} from '@blocksuite/icons/lit'; -import { css, html } from 'lit'; -import { styleMap } from 'lit/directives/style-map.js'; - -import type { SingleView } from '../../../../core/view-manager/single-view.js'; - -import { - popGroupSetting, - popSelectGroupByProperty, -} from '../../../../core/common/group-by/setting.js'; -import { popPropertiesSetting } from '../../../../core/common/properties.js'; -import { popCreateFilter } from '../../../../core/common/ref/ref.js'; -import { emptyFilterGroup, renderUniLit } from '../../../../core/index.js'; -import { WidgetBase } from '../../../../core/widget/widget-base.js'; -import { - TableSingleView, - type TableViewData, -} from '../../../../view-presets/index.js'; -import { popFilterRoot } from '../../../filter/filter-modal.js'; - -const styles = css` - .affine-microsheet-toolbar-item.more-action { - padding: 2px; - border-radius: 4px; - display: flex; - align-items: center; - cursor: pointer; - } - - .affine-microsheet-toolbar-item.more-action:hover { - background: var(--affine-hover-color); - } - - .affine-microsheet-toolbar-item.more-action svg { - width: 20px; - height: 20px; - fill: var(--affine-icon-color); - } - - .more-action.active { - background: var(--affine-hover-color); - } -`; - -export class DataViewHeaderToolsViewOptions extends WidgetBase { - static override styles = styles; - - clickMoreAction = (e: MouseEvent) => { - e.stopPropagation(); - this.openMoreAction(popupTargetFromElement(e.currentTarget as HTMLElement)); - }; - - openMoreAction = (target: PopupTarget) => { - this.showToolBar(true); - popViewOptions(target, this.view, () => { - this.showToolBar(false); - }); - }; - - override render() { - if (this.view.readonly$.value) { - return; - } - return html`
- ${MoreHorizontalIcon()} -
`; - } - - showToolBar(show: boolean) { - const tools = this.closest('data-view-header-tools'); - if (tools) { - tools.showToolBar = show; - } - } - - override accessor view!: SingleView; -} - -declare global { - interface HTMLElementTagNameMap { - 'data-view-header-tools-view-options': DataViewHeaderToolsViewOptions; - } -} -export const popViewOptions = ( - target: PopupTarget, - view: SingleView, - onClose?: () => void -) => { - const reopen = () => { - popViewOptions(target, view); - }; - popMenu(target, { - options: { - title: { - text: 'View settings', - }, - items: [ - menu.input({ - initialValue: view.name$.value, - onComplete: text => { - view.nameSet(text); - }, - }), - menu.action({ - name: 'Layout', - postfix: html`
- ${view.type} -
- ${ArrowRightSmallIcon()}`, - select: () => { - const viewTypes = view.manager.viewMetas.map(meta => { - return menu => { - if (!menu.search(meta.model.defaultName)) { - return; - } - const isSelected = - meta.type === view.manager.currentView$.value.type; - const iconStyle = styleMap({ - fontSize: '24px', - color: isSelected - ? 'var(--affine-text-emphasis-color)' - : 'var(--affine-icon-secondary)', - }); - const textStyle = styleMap({ - fontSize: '14px', - lineHeight: '22px', - color: isSelected - ? 'var(--affine-text-emphasis-color)' - : 'var(--affine-text-secondary-color)', - }); - const data: MenuButtonData = { - content: () => html` -
-
- ${renderUniLit(meta.renderer.icon)} -
-
${meta.model.defaultName}
-
- `, - select: () => {}, - class: '', - }; - const containerStyle = styleMap({ - flex: '1', - }); - return html` `; - }; - }); - popMenu(target, { - options: { - title: { - onBack: reopen, - text: 'Layout', - }, - items: [ - menu => { - const result = menu.renderItems(viewTypes); - if (result.length) { - return html`
${result}
`; - } - return html``; - }, - // menu.toggleSwitch({ - // name: 'Show block icon', - // on: true, - // onChange: value => { - // console.log(value); - // }, - // }), - // menu.toggleSwitch({ - // name: 'Show Vertical lines', - // on: true, - // onChange: value => { - // console.log(value); - // }, - // }), - ], - }, - }); - }, - prefix: LayoutIcon(), - }), - menu.group({ - items: [ - menu.action({ - name: 'Properties', - prefix: InfoIcon(), - postfix: html`
- ${view.properties$.value.length} shown -
- ${ArrowRightSmallIcon()}`, - select: () => { - popPropertiesSetting(target, { - view: view, - onBack: reopen, - }); - }, - }), - menu.action({ - name: 'Filter', - prefix: FilterIcon(), - postfix: html`
- ${view.filter$.value.conditions.length - ? `${view.filter$.value.conditions.length} filters` - : ''} -
- ${ArrowRightSmallIcon()}`, - select: () => { - if (!view.filter$.value.conditions.length) { - popCreateFilter(target, { - vars: view.vars$, - onBack: reopen, - onSelect: filter => { - console.log(filter, view.filter$.value); - view.filterSet({ - ...(view.filter$.value ?? emptyFilterGroup), - conditions: [...view.filter$.value.conditions, filter], - }); - popFilterRoot(target, { - view: view, - onBack: reopen, - }); - }, - }); - } else { - popFilterRoot(target, { - view: view, - onBack: reopen, - }); - } - }, - }), - menu.action({ - name: 'Group', - prefix: GroupingIcon(), - postfix: html`
- ${view instanceof TableSingleView - ? view.groupManager.property$.value?.name$.value - : ''} -
- ${ArrowRightSmallIcon()}`, - select: () => { - const groupBy = view.data$.value?.groupBy; - if (!groupBy) { - popSelectGroupByProperty(target, view, { - onSelect: () => popGroupSetting(target, view, reopen), - onBack: reopen, - }); - } else { - popGroupSetting(target, view, reopen); - } - }, - }), - ], - }), - menu.group({ - items: [ - menu.action({ - name: 'Duplicate', - prefix: DuplicateIcon(), - select: () => { - view.duplicate(); - }, - }), - menu.action({ - name: 'Delete', - prefix: DeleteIcon(), - select: () => { - view.delete(); - }, - class: 'delete-item', - }), - ], - }), - ], - onClose: onClose, - }, - }); -}; diff --git a/packages/affine/microsheet-data-view/src/widget-presets/tools/tools-renderer.ts b/packages/affine/microsheet-data-view/src/widget-presets/tools/tools-renderer.ts index bc5086b50466..0cba0ea6cb4b 100644 --- a/packages/affine/microsheet-data-view/src/widget-presets/tools/tools-renderer.ts +++ b/packages/affine/microsheet-data-view/src/widget-presets/tools/tools-renderer.ts @@ -6,8 +6,8 @@ import { repeat } from 'lit/directives/repeat.js'; import type { SingleView } from '../../core/view-manager/single-view.js'; import type { ViewManager } from '../../core/view-manager/view-manager.js'; import type { - DataViewWidget, - DataViewWidgetProps, + MicrosheetDataViewWidget, + MicrosheetDataViewWidgetProps, } from '../../core/widget/types.js'; import { type DataViewExpose, renderUniLit } from '../../core/index.js'; @@ -51,7 +51,7 @@ export class DataViewHeaderTools extends WidgetBase { const tools = this.toolsMap[this.view.type]; return html`
${repeat(tools ?? [], uni => { - const props: DataViewWidgetProps = { + const props: MicrosheetDataViewWidgetProps = { view: this.view, viewMethods: this.viewMethods, }; @@ -64,12 +64,12 @@ export class DataViewHeaderTools extends WidgetBase { accessor showToolBar = false; @property({ attribute: false }) - accessor toolsMap!: Record; + accessor toolsMap!: Record; } declare global { interface HTMLElementTagNameMap { - 'data-view-header-tools': DataViewHeaderTools; + 'microsheet-data-view-header-tools': DataViewHeaderTools; } } export const renderTools = ( @@ -77,7 +77,7 @@ export const renderTools = ( viewMethods: DataViewExpose, viewSource: ViewManager ) => { - return html` { - popFilterableSimpleMenu( - popupTargetFromElement(event.currentTarget as HTMLElement), - this.dataSource.viewMetas.map(v => { - return menu.action({ - name: v.model.defaultName, - prefix: html``, - select: () => { - const id = this.viewManager.viewAdd(v.type); - this.viewManager.setCurrentView(id); - }, - }); - }) - ); - }; - - _showMore = (event: MouseEvent) => { - const views = this.viewManager.views$.value; - popFilterableSimpleMenu( - popupTargetFromElement(event.currentTarget as HTMLElement), - [ - ...views.map(id => { - const openViewOption = (event: MouseEvent) => { - event.stopPropagation(); - this.openViewOption( - popupTargetFromElement(event.currentTarget as HTMLElement), - id - ); - }; - const view = this.viewManager.viewGet(id); - return menu.action({ - prefix: html``, - name: view.data$.value?.name ?? '', - label: () => html`${view.data$.value?.name}`, - isSelected: this.viewManager.currentViewId$.value === id, - select: () => { - this.viewManager.setCurrentView(id); - }, - postfix: html`
- ${MoreHorizontalIcon()} -
`, - }); - }), - menu.group({ - items: this.dataSource.viewMetas.map(v => { - return menu.action({ - name: `Create ${v.model.defaultName}`, - hide: () => this.readonly, - prefix: html``, - select: () => { - const id = this.viewManager.viewAdd(v.type); - this.viewManager.setCurrentView(id); - }, - }); - }), - }), - ] - ); - }; - - openViewOption = (target: PopupTarget, id: string) => { - if (this.readonly) { - return; - } - const views = this.viewManager.views$.value; - const index = views.findIndex(v => v === id); - const view = this.viewManager.viewGet(views[index]); - if (!view) { - return; - } - popMenu(target, { - options: { - items: [ - menu.input({ - initialValue: view.data$.value?.name, - onComplete: text => { - view.dataUpdate(_data => ({ - name: text, - })); - }, - }), - menu.action({ - name: 'Edit View', - prefix: InfoIcon(), - select: () => { - this.closest('affine-microsheet-data-view-renderer') - ?.querySelector('data-view-header-tools-view-options') - ?.openMoreAction(target); - }, - }), - menu.action({ - name: 'Move Left', - hide: () => index === 0, - prefix: MoveLeftIcon(), - select: () => { - const targetId = views[index - 1]; - this.viewManager.moveTo( - id, - targetId ? { before: true, id: targetId } : 'start' - ); - }, - }), - menu.action({ - name: 'Move Right', - prefix: MoveRightIcon(), - hide: () => index === views.length - 1, - select: () => { - const targetId = views[index + 1]; - this.viewManager.moveTo( - id, - targetId ? { before: false, id: targetId } : 'end' - ); - }, - }), - menu.group({ - items: [ - menu.action({ - name: 'Duplicate', - prefix: DuplicateIcon(), - select: () => { - this.viewManager.viewDuplicate(id); - }, - }), - menu.action({ - name: 'Delete', - prefix: DeleteIcon(), - select: () => { - view.delete(); - }, - class: 'delete-item', - }), - ], - }), - ], - }, - }); - }; - - renderMore = (count: number) => { - const views = this.viewManager.views$.value; - if (count === views.length) { - if (this.readonly) { - return; - } - return html`
- ${AddCursorIcon()} -
`; - } - return html` -
- ${views.length - count} More -
- `; - }; - - renderViews = () => { - const views = this.viewManager.views$.value; - return views.map(id => () => { - const classList = classMap({ - 'microsheet-view-button': true, - 'dv-hover': true, - active: this.viewManager.currentViewId$.value === id, - }); - const view = this.viewManager.viewDataGet(id); - return html` -
- -
${view?.name}
-
- `; - }); - }; - - get readonly() { - return this.viewManager.readonly$.value; - } - - private getRenderer(viewId: string) { - return this.dataSource.viewMetaGetById(viewId).renderer; - } - - _clickView(event: MouseEvent, id: string) { - if (this.viewManager.currentViewId$.value !== id) { - this.viewManager.setCurrentView(id); - return; - } - this.openViewOption( - popupTargetFromElement(event.currentTarget as HTMLElement), - id - ); - } - - override render() { - return html` - - `; - } -} - -declare global { - interface HTMLElementTagNameMap { - 'data-view-header-views': DataViewHeaderViews; - } -} diff --git a/packages/affine/model/src/blocks/database/types.ts b/packages/affine/model/src/blocks/database/types.ts index c168606bf463..b0e5aa4c40f9 100644 --- a/packages/affine/model/src/blocks/database/types.ts +++ b/packages/affine/model/src/blocks/database/types.ts @@ -11,7 +11,6 @@ export type ColumnUpdater = (data: T) => Partial; export type Cell = { columnId: Column['id']; value: ValueType; - ref: string; }; export type SerializedCells = Record>; diff --git a/packages/affine/model/src/blocks/microsheet/index.ts b/packages/affine/model/src/blocks/microsheet/index.ts index 9351b5c89b94..d650b1350482 100644 --- a/packages/affine/model/src/blocks/microsheet/index.ts +++ b/packages/affine/model/src/blocks/microsheet/index.ts @@ -1 +1,2 @@ export * from './microsheet-model.js'; +export * from './types.js'; diff --git a/packages/affine/model/src/blocks/microsheet/microsheet-model.ts b/packages/affine/model/src/blocks/microsheet/microsheet-model.ts index a03aea7c47f7..4b52f4d0479e 100644 --- a/packages/affine/model/src/blocks/microsheet/microsheet-model.ts +++ b/packages/affine/model/src/blocks/microsheet/microsheet-model.ts @@ -2,7 +2,11 @@ import type { Text } from '@blocksuite/store'; import { BlockModel, defineBlockSchema } from '@blocksuite/store'; -import type { Column, SerializedCells, ViewBasicDataType } from './types.js'; +import type { + MicrosheetColumn as Column, + MicrosheetSerializedCells as SerializedCells, + MicrosheetViewBasicDataType as ViewBasicDataType, +} from './types.js'; export type MicrosheetBlockProps = { views: ViewBasicDataType[]; diff --git a/packages/affine/model/src/blocks/microsheet/types.ts b/packages/affine/model/src/blocks/microsheet/types.ts index c168606bf463..52c22638166d 100644 --- a/packages/affine/model/src/blocks/microsheet/types.ts +++ b/packages/affine/model/src/blocks/microsheet/types.ts @@ -1,4 +1,4 @@ -export interface Column< +export interface MicrosheetColumn< Data extends Record = Record, > { id: string; @@ -7,15 +7,20 @@ export interface Column< data: Data; } -export type ColumnUpdater = (data: T) => Partial; -export type Cell = { - columnId: Column['id']; +export type MicrosheetColumnUpdater< + T extends MicrosheetColumn = MicrosheetColumn, +> = (data: T) => Partial; +export type MicrosheetCell = { + columnId: MicrosheetColumn['id']; value: ValueType; ref: string; }; -export type SerializedCells = Record>; -export type ViewBasicDataType = { +export type MicrosheetSerializedCells = Record< + string, + Record +>; +export type MicrosheetViewBasicDataType = { id: string; name: string; mode: string; diff --git a/packages/affine/model/src/blocks/note/note-model.ts b/packages/affine/model/src/blocks/note/note-model.ts index 67e6547b2469..3dfa897075b3 100644 --- a/packages/affine/model/src/blocks/note/note-model.ts +++ b/packages/affine/model/src/blocks/note/note-model.ts @@ -46,6 +46,7 @@ export const NoteBlockSchema = defineBlockSchema({ 'affine:divider', 'affine:database', 'affine:microsheet', + 'affine:microsheet-data-view', 'affine:data-view', 'affine:image', 'affine:bookmark', diff --git a/packages/affine/shared/src/types/index.ts b/packages/affine/shared/src/types/index.ts index 007de5555978..1da220e6c0f9 100644 --- a/packages/affine/shared/src/types/index.ts +++ b/packages/affine/shared/src/types/index.ts @@ -20,6 +20,8 @@ export type NoteChildrenFlavour = | 'affine:divider' | 'affine:database' | 'affine:data-view' + | 'affine:microsheet' + | 'affine:microsheet-data-view' | 'affine:image' | 'affine:bookmark' | 'affine:attachment' diff --git a/packages/blocks/src/_specs/common.ts b/packages/blocks/src/_specs/common.ts index 71f22f5173d2..f5b4f985f7a7 100644 --- a/packages/blocks/src/_specs/common.ts +++ b/packages/blocks/src/_specs/common.ts @@ -15,6 +15,7 @@ import { DatabaseBlockSpec } from '../database-block/database-spec.js'; import { DividerBlockSpec } from '../divider-block/divider-spec.js'; import { ImageBlockSpec } from '../image-block/image-spec.js'; import { MicrosheetBlockSpec } from '../microsheet-block/microsheet-spec.js'; +import { MicrosheetDataViewBlockSpec } from '../microsheet-data-view-block/data-view-spec.js'; import { EdgelessNoteBlockSpec, NoteBlockSpec, @@ -31,6 +32,7 @@ export const CommonFirstPartyBlockSpecs: ExtensionType[] = [ RowBlockSpec, CellBlockSpec, DataViewBlockSpec, + MicrosheetDataViewBlockSpec, DividerBlockSpec, CodeBlockSpec, ImageBlockSpec, @@ -50,6 +52,7 @@ export const EdgelessFirstPartyBlockSpecs: ExtensionType[] = [ RowBlockSpec, CellBlockSpec, DataViewBlockSpec, + MicrosheetDataViewBlockSpec, DividerBlockSpec, CodeBlockSpec, ImageBlockSpec, diff --git a/packages/blocks/src/_specs/group/common.ts b/packages/blocks/src/_specs/group/common.ts index 39f638c18b9d..5e37a4197772 100644 --- a/packages/blocks/src/_specs/group/common.ts +++ b/packages/blocks/src/_specs/group/common.ts @@ -18,6 +18,7 @@ import { DatabaseBlockSpec } from '../../database-block/database-spec.js'; import { DividerBlockSpec } from '../../divider-block/divider-spec.js'; import { ImageBlockSpec } from '../../image-block/image-spec.js'; import { MicrosheetBlockSpec } from '../../microsheet-block/microsheet-spec.js'; +import { MicrosheetDataViewBlockSpec } from '../../microsheet-data-view-block/data-view-spec.js'; import { EdgelessNoteBlockSpec, NoteBlockSpec, @@ -41,6 +42,7 @@ export { ImageBlockSpec, ListBlockSpec, MicrosheetBlockSpec, + MicrosheetDataViewBlockSpec, NoteBlockSpec, ParagraphBlockSpec, }; diff --git a/packages/blocks/src/cell-block/cell-block.ts b/packages/blocks/src/cell-block/cell-block.ts index 44c433ded78d..c5c488d7fabf 100644 --- a/packages/blocks/src/cell-block/cell-block.ts +++ b/packages/blocks/src/cell-block/cell-block.ts @@ -4,6 +4,7 @@ import type { CellBlockModel } from '@blocksuite/affine-model'; import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption'; import { html } from 'lit'; +import { property } from 'lit/decorators.js'; import type { CellBlockService } from './cell-service.js'; @@ -26,6 +27,9 @@ export class CellBlockComponent extends CaptionedBlockComponent< override renderBlock() { return html`${this.renderChildren(this.model)}`; } + + @property({ attribute: false }) + override accessor widgets = {}; } declare global { diff --git a/packages/blocks/src/cell-block/cell-service.ts b/packages/blocks/src/cell-block/cell-service.ts index ca1d72bf95ed..243aa77bf9a9 100644 --- a/packages/blocks/src/cell-block/cell-service.ts +++ b/packages/blocks/src/cell-block/cell-service.ts @@ -17,12 +17,6 @@ export const selectBlock: Command<'focusBlock'> = (ctx, next) => { return; } - // const { selection } = std; - - // selection.setGroup('cell', [ - // selection.create('block', { path: focusBlock.path }), - // ]); - return next(); }; diff --git a/packages/blocks/src/cell-block/keymap-controller.ts b/packages/blocks/src/cell-block/keymap-controller.ts index a8e1222bd634..519bd48404df 100644 --- a/packages/blocks/src/cell-block/keymap-controller.ts +++ b/packages/blocks/src/cell-block/keymap-controller.ts @@ -1,282 +1,28 @@ /* eslint-disable */ -import type { BlockSelection, UIEventHandler } from '@blocksuite/block-std'; -import type { BlockElement } from '@blocksuite/lit'; +import type { + BaseSelection, + BlockComponent, + UIEventHandler, + UIEventStateContext, +} from '@blocksuite/block-std'; import type { ReactiveController } from 'lit'; -import { assertExists } from '@blocksuite/global/utils'; import type { ReactiveControllerHost } from 'lit'; +import type { BlockModel } from '@blocksuite/store'; export const ensureBlockInContainer = ( - blockElement: BlockElement, - containerElement: BlockElement + blockElement: BlockComponent, + containerElement: BlockComponent ) => containerElement.contains(blockElement) && blockElement !== containerElement; export class KeymapController implements ReactiveController { - private _anchorSel: BlockSelection | null = null; - private _focusBlock: BlockElement | null = null; - - host: ReactiveControllerHost & BlockElement; - - private get _std() { - return this.host.std; - } - - constructor(host: ReactiveControllerHost & BlockElement) { - (this.host = host).addController(this); - } - - hostConnected() { - this._reset(); - } - - hostDisconnected() { - this._reset(); - } - - private _reset = () => { - this._anchorSel = null; - this._focusBlock = null; - }; - - bind = () => { - this.host.handleEvent('keyDown', ctx => { - const state = ctx.get('keyboardState'); - if (state.raw.key === 'Shift') { - return; - } - this._reset(); - }); - - this.host.bindHotKey({ - // ArrowDown: this._onArrowDown, - // ArrowUp: this._onArrowUp, - // 'Shift-ArrowDown': this._onShiftArrowDown, - // 'Shift-ArrowUp': this._onShiftArrowUp, - // Escape: this._onEsc, - Enter: this._onEnter, - 'Mod-a': this._onSelectAll, - }); - - // this._bindQuickActionHotKey(); - // this._bindTextConversionHotKey(); - // this._bindMoveBlockHotKey(); - }; - - private _onArrowDown = () => { - const [result] = this._std.command - .pipe() - .inline((_, next) => { - this._reset(); - return next(); - }) - .try(cmd => [ - // block selection - select the next block - this._onBlockDown(cmd), - ]) - .run(); - - return result; - }; - - private _onBlockDown = (cmd: BlockSuite.CommandChain) => { - return cmd - .getBlockSelections() - .inline<'currentSelectionPath'>((ctx, next) => { - const currentBlockSelections = ctx.currentBlockSelections; - assertExists(currentBlockSelections); - const blockSelection = currentBlockSelections.at(-1); - if (!blockSelection) { - return; - } - return next({ currentSelectionPath: blockSelection.path }); - }) - .getNextBlock() - .inline<'focusBlock'>((ctx, next) => { - const { nextBlock } = ctx; - assertExists(nextBlock); - - if (!ensureBlockInContainer(nextBlock, this.host)) { - return; - } - - return next({ - focusBlock: nextBlock, - }); - }) - .selectBlock(); - }; - - private _onArrowUp = () => { - const [result] = this._std.command - .pipe() - .inline((_, next) => { - this._reset(); - return next(); - }) - .try(cmd => [ - // block selection - select the previous block - this._onBlockUp(cmd), - ]) - .run(); - - return result; - }; - - private _onBlockUp = (cmd: BlockSuite.CommandChain) => { - return cmd - .getBlockSelections() - .inline<'currentSelectionPath'>((ctx, next) => { - const currentBlockSelections = ctx.currentBlockSelections; - assertExists(currentBlockSelections); - const blockSelection = currentBlockSelections.at(0); - if (!blockSelection) { - return; - } - return next({ currentSelectionPath: blockSelection.path }); - }) - .getPrevBlock() - .inline((ctx, next) => { - const { prevBlock } = ctx; - assertExists(prevBlock); - - if (!ensureBlockInContainer(prevBlock, this.host)) { - return; - } - - return next({ - focusBlock: prevBlock, - }); - }) - .selectBlock(); - }; - - private _onShiftArrowDown = () => { - const [result] = this._std.command - .pipe() - .try(cmd => [ - // block selection - this._onBlockShiftDown(cmd), - ]) - .run(); - - return result; - }; - - private _onBlockShiftDown = (cmd: BlockSuite.CommandChain) => { - return cmd - .getBlockSelections() - .inline<'currentSelectionPath' | 'anchorBlock'>((ctx, next) => { - const blockSelections = ctx.currentBlockSelections; - assertExists(blockSelections); - if (!this._anchorSel) { - this._anchorSel = blockSelections.at(-1) ?? null; - } - if (!this._anchorSel) { - return; - } - - const anchorBlock = ctx.std.view.viewFromPath( - 'block', - this._anchorSel.path - ); - if (!anchorBlock) { - return; - } - return next({ - anchorBlock, - currentSelectionPath: this._focusBlock?.path ?? anchorBlock?.path, - }); - }) - .getNextBlock({}) - .inline<'focusBlock'>((ctx, next) => { - assertExists(ctx.nextBlock); - this._focusBlock = ctx.nextBlock; - if (!ensureBlockInContainer(this._focusBlock, this.host)) { - return; - } - return next({ - focusBlock: this._focusBlock, - }); - }) - .selectBlocksBetween({ tail: true }); - }; - - private _onShiftArrowUp = () => { - const [result] = this._std.command - .pipe() - .try(cmd => [ - // block selection - this._onBlockShiftUp(cmd), - ]) - .run(); - - return result; - }; - - private _onBlockShiftUp = (cmd: BlockSuite.CommandChain) => { - return cmd - .getBlockSelections() - .inline<'currentSelectionPath' | 'anchorBlock'>((ctx, next) => { - const blockSelections = ctx.currentBlockSelections; - assertExists(blockSelections); - if (!this._anchorSel) { - this._anchorSel = blockSelections.at(0) ?? null; - } - if (!this._anchorSel) { - return; - } - const anchorBlock = ctx.std.view.viewFromPath( - 'block', - this._anchorSel.path - ); - if (!anchorBlock) { - return; - } - return next({ - anchorBlock, - currentSelectionPath: this._focusBlock?.path ?? anchorBlock?.path, - }); - }) - .getPrevBlock({}) - .inline((ctx, next) => { - assertExists(ctx.prevBlock); - this._focusBlock = ctx.prevBlock; - if (!ensureBlockInContainer(this._focusBlock, this.host)) { - return; - } - return next({ - focusBlock: this._focusBlock, - }); - }) - .selectBlocksBetween({ tail: false }); - }; - - private _onEsc = () => { - const [result] = this._std.command - .pipe() - .getBlockSelections() - .inline((ctx, next) => { - const blockSelection = ctx.currentBlockSelections?.at(-1); - if (!blockSelection) { - return; - } - - ctx.std.selection.update(selList => { - return selList.filter(sel => !sel.is('block')); - }); - - return next(); - }) - .run(); - - return result; - }; - private _onEnter = () => { const [result] = this._std.command + // @ts-expect-error .pipe() .getBlockSelections() + // @ts-expect-error .inline((ctx, next) => { const blockSelection = ctx.currentBlockSelections?.at(-1); if (!blockSelection) { @@ -324,22 +70,58 @@ export class KeymapController implements ReactiveController { return result; }; - private _onSelectAll: UIEventHandler = ctx => { + private _onSelectAll: UIEventHandler = () => { const childrenModels = this.host.model.children; if ( this._std.selection.filter('block').length === childrenModels.length && this._std.selection .filter('block') - .every(block => - childrenModels.some(model => model.id === block.blockId) + .every((block: BaseSelection) => + childrenModels.some((model: BlockModel) => model.id === block.blockId) ) ) { return; } - const childrenBlocksSelection = this.host.model.children.map(model => - this._std.selection.create('block', { blockId: model.id }) + const childrenBlocksSelection = this.host.model.children.map( + (model: BlockModel) => + this._std.selection.create('block', { blockId: model.id }) ); this._std.selection.setGroup('note', childrenBlocksSelection); return true; }; + + private _reset = () => {}; + + bind = () => { + this.host.handleEvent('keyDown', (ctx: UIEventStateContext) => { + const state = ctx.get('keyboardState'); + if (state.raw.key === 'Shift') { + return; + } + this._reset(); + }); + + this.host.bindHotKey({ + Enter: this._onEnter, + 'Mod-a': this._onSelectAll, + }); + }; + + host: ReactiveControllerHost & BlockComponent; + + private get _std() { + return this.host.std; + } + + constructor(host: ReactiveControllerHost & BlockComponent) { + (this.host = host).addController(this); + } + + hostConnected() { + this._reset(); + } + + hostDisconnected() { + this._reset(); + } } diff --git a/packages/blocks/src/effects.ts b/packages/blocks/src/effects.ts index 5df147b752c2..f0ee36e2a5a8 100644 --- a/packages/blocks/src/effects.ts +++ b/packages/blocks/src/effects.ts @@ -115,6 +115,7 @@ import { MicrosheetBlockComponent, type MicrosheetBlockService, } from './microsheet-block/index.js'; +import { MicrosheetDataViewBlockComponent } from './microsheet-data-view-block/data-view-block.js'; import { EdgelessNoteBlockComponent, EdgelessNoteMask, @@ -398,6 +399,10 @@ export function effects() { customElements.define('affine-frame', FrameBlockComponent); customElements.define('mini-mindmap-surface-block', MindmapSurfaceBlock); customElements.define('affine-data-view', DataViewBlockComponent); + customElements.define( + 'affine-microsheet-data-view', + MicrosheetDataViewBlockComponent + ); customElements.define('affine-edgeless-root', EdgelessRootBlockComponent); customElements.define('affine-divider', DividerBlockComponent); customElements.define('edgeless-copilot-panel', EdgelessCopilotPanel); diff --git a/packages/blocks/src/microsheet-block/data-source.ts b/packages/blocks/src/microsheet-block/data-source.ts index cca6e5a1f213..7951236626a8 100644 --- a/packages/blocks/src/microsheet-block/data-source.ts +++ b/packages/blocks/src/microsheet-block/data-source.ts @@ -9,7 +9,6 @@ import { assertExists } from '@blocksuite/global/utils'; import { DataSourceBase, type DataViewDataType, - type MicrosheetFlags, type PropertyMetaConfig, type TType, type ViewManager, @@ -24,7 +23,6 @@ import { getIcon } from './block-icons.js'; import { microsheetBlockAllPropertyMap, microsheetBlockPropertyList, - microsheetPropertyConverts, } from './properties/index.js'; import { titlePurePropertyConfig } from './properties/title/define.js'; import { @@ -40,7 +38,6 @@ import { getProperty, moveViewTo, updateCell, - updateCells, updateProperty, updateView, } from './utils.js'; @@ -51,15 +48,6 @@ export class MicrosheetBlockDataSource extends DataSourceBase { private readonly _model: MicrosheetBlockModel; - override featureFlags$: ReadonlySignal = computed(() => { - return { - enable_number_formatting: - this.doc.awarenessStore.getFlag( - 'enable_microsheet_number_formatting' - ) ?? false, - }; - }); - properties$: ReadonlySignal = computed(() => { return this._model.columns$.value.map(column => column.id); }); @@ -174,7 +162,7 @@ export class MicrosheetBlockDataSource extends DataSourceBase { this._model, insertToPosition, microsheetBlockAllPropertyMap[ - type ?? propertyPresets.multiSelectPropertyConfig.type + type ?? propertyPresets.textPropertyConfig.type ].create(this.newPropertyName()) ); applyPropertyUpdate(this._model); @@ -276,40 +264,6 @@ export class MicrosheetBlockDataSource extends DataSourceBase { ); } - propertyTypeSet(propertyId: string, toType: string): void { - const currentType = this.propertyTypeGet(propertyId); - const currentData = this.propertyDataGet(propertyId); - const rows = this.rows$.value; - const currentCells = rows.map(rowId => - this.cellValueGet(rowId, propertyId) - ); - const convertFunction = microsheetPropertyConverts.find( - v => v.from === currentType && v.to === toType - )?.convert; - const result = convertFunction?.( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - currentData as any, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - currentCells as any - ) ?? { - property: microsheetBlockAllPropertyMap[toType].config.defaultData(), - cells: currentCells.map(() => undefined), - }; - this.doc.captureSync(); - updateProperty(this._model, propertyId, () => ({ - type: toType, - data: result.property, - })); - const cells: Record = {}; - currentCells.forEach((value, i) => { - if (value != null || result.cells[i] != null) { - cells[rows[i]] = result.cells[i]; - } - }); - updateCells(this._model, propertyId, cells); - applyPropertyUpdate(this._model); - } - refContentDelete(rowId: string, columnId: string): void { const cellId = this.cellRefGet(rowId, columnId); const doc = this.doc; @@ -447,7 +401,7 @@ export const microsheetViewInitConvert = ( addProperty( model, 'end', - propertyPresets.multiSelectPropertyConfig.create('Tag', { options: [] }) + propertyPresets.textPropertyConfig.create('Tag', {}) ); microsheetViewInitEmpty(model, viewType); }; diff --git a/packages/blocks/src/microsheet-block/detail-panel/block-renderer.ts b/packages/blocks/src/microsheet-block/detail-panel/block-renderer.ts deleted file mode 100644 index 6986af79e8a5..000000000000 --- a/packages/blocks/src/microsheet-block/detail-panel/block-renderer.ts +++ /dev/null @@ -1,156 +0,0 @@ -import type { EditorHost } from '@blocksuite/block-std'; -import type { DetailSlotProps } from '@blocksuite/microsheet-data-view'; -import type { TableSingleView } from '@blocksuite/microsheet-data-view/view-presets'; - -import { DefaultInlineManagerExtension } from '@blocksuite/affine-components/rich-text'; -import { ShadowlessElement } from '@blocksuite/block-std'; -import { WithDisposable } from '@blocksuite/global/utils'; -import { css, html } from 'lit'; -import { property } from 'lit/decorators.js'; - -export class BlockRenderer - extends WithDisposable(ShadowlessElement) - implements DetailSlotProps -{ - static override styles = css` - microsheet-datasource-block-renderer { - padding-top: 36px; - padding-bottom: 16px; - display: flex; - flex-direction: column; - gap: 16px; - margin-bottom: 12px; - border-bottom: 1px solid var(--affine-border-color); - font-size: var(--affine-font-base); - line-height: var(--affine-line-height); - } - - microsheet-datasource-block-renderer .tips-placeholder { - display: none; - } - - microsheet-datasource-block-renderer rich-text { - font-size: 15px; - line-height: 24px; - } - - microsheet-datasource-block-renderer.empty rich-text::before { - content: 'Untitled'; - position: absolute; - color: var(--affine-text-disable-color); - font-size: 15px; - line-height: 24px; - user-select: none; - pointer-events: none; - } - - .microsheet-block-detail-header-icon { - width: 20px; - height: 20px; - padding: 2px; - border-radius: 4px; - background-color: var(--affine-background-secondary-color); - } - - .microsheet-block-detail-header-icon svg { - width: 16px; - height: 16px; - } - `; - - get attributeRenderer() { - return this.inlineManager.getRenderer(); - } - - get attributesSchema() { - return this.inlineManager.getSchema(); - } - - get inlineManager() { - return this.host.std.get(DefaultInlineManagerExtension.identifier); - } - - get model() { - return this.host?.doc.getBlock(this.rowId)?.model; - } - - get service() { - return this.host.std.getService('affine:microsheet'); - } - - override connectedCallback() { - super.connectedCallback(); - if (this.model && this.model.text) { - const cb = () => { - if (this.model?.text?.length == 0) { - this.classList.add('empty'); - } else { - this.classList.remove('empty'); - } - }; - this.model.text.yText.observe(cb); - this.disposables.add(() => { - this.model?.text?.yText.unobserve(cb); - }); - } - this._disposables.addFromEvent( - this, - 'keydown', - e => { - if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { - e.stopPropagation(); - e.preventDefault(); - return; - } - if ( - e.key === 'Backspace' && - !e.shiftKey && - !e.metaKey && - this.model?.text?.length === 0 - ) { - e.stopPropagation(); - e.preventDefault(); - return; - } - }, - true - ); - } - - protected override render(): unknown { - const model = this.model; - if (!model) { - return; - } - return html` - ${this.renderIcon()} - - `; - } - - renderIcon() { - const iconColumn = this.view.mainProperties$.value.iconColumn; - if (!iconColumn) { - return; - } - return html`
- ${this.view.cellValueGet(this.rowId, iconColumn)} -
`; - } - - @property({ attribute: false }) - accessor host!: EditorHost; - - @property({ attribute: false }) - accessor rowId!: string; - - @property({ attribute: false }) - accessor view!: TableSingleView; -} diff --git a/packages/blocks/src/microsheet-block/detail-panel/note-renderer.ts b/packages/blocks/src/microsheet-block/detail-panel/note-renderer.ts deleted file mode 100644 index 65b6ff767e1a..000000000000 --- a/packages/blocks/src/microsheet-block/detail-panel/note-renderer.ts +++ /dev/null @@ -1,132 +0,0 @@ -import type { MicrosheetBlockModel } from '@blocksuite/affine-model'; -import type { - DetailSlotProps, - SingleView, -} from '@blocksuite/microsheet-data-view'; - -import { focusTextModel } from '@blocksuite/affine-components/rich-text'; -import { - createDefaultDoc, - matchFlavours, -} from '@blocksuite/affine-shared/utils'; -import { - BlockStdScope, - type EditorHost, - ShadowlessElement, -} from '@blocksuite/block-std'; -import { WithDisposable } from '@blocksuite/global/utils'; -import { css, html } from 'lit'; -import { property, query } from 'lit/decorators.js'; -export class NoteRenderer - extends WithDisposable(ShadowlessElement) - implements DetailSlotProps -{ - static override styles = css` - microsheet-datasource-note-renderer { - width: 100%; - --affine-editor-side-padding: 0; - flex: 1; - } - `; - - get microsheetBlock(): MicrosheetBlockModel { - return this.model; - } - - addNote() { - const collection = this.host?.std.collection; - if (!collection) { - return; - } - if (!this.microsheetBlock.notes) { - this.microsheetBlock.notes = {}; - } - const note = createDefaultDoc(collection); - if (note) { - this.microsheetBlock.notes[this.rowId] = note.id; - this.requestUpdate(); - requestAnimationFrame(() => { - const block = note.root?.children - .find(child => child.flavour === 'affine:note') - ?.children.find(block => - matchFlavours(block, [ - 'affine:paragraph', - 'affine:list', - 'affine:code', - ]) - ); - if (this.subHost && block) { - focusTextModel(this.subHost.std, block.id); - } - }); - } - } - - override connectedCallback() { - super.connectedCallback(); - this.microsheetBlock.propsUpdated.on(({ key }) => { - if (key === 'notes') { - this.requestUpdate(); - } - }); - } - - protected override render(): unknown { - if ( - !this.model.doc.awarenessStore.getFlag( - 'enable_microsheet_attachment_note' - ) - ) { - return null; - } - return html` -
- ${this.renderNote()} - `; - } - - renderNote() { - const host = this.host; - const std = host?.std; - if (!std || !host) { - return; - } - const pageId = this.microsheetBlock.notes?.[this.rowId]; - if (!pageId) { - return html`
-
- Click to add note -
-
`; - } - const page = std.collection.getDoc(pageId); - if (!page) { - return; - } - const previewStd = new BlockStdScope({ - doc: page, - extensions: std.userExtensions, - }); - return html`${previewStd.render()} `; - } - - @property({ attribute: false }) - accessor host!: EditorHost; - - @property({ attribute: false }) - accessor model!: MicrosheetBlockModel; - - @property({ attribute: false }) - accessor rowId!: string; - - @query('editor-host') - accessor subHost!: EditorHost; - - @property({ attribute: false }) - accessor view!: SingleView; -} diff --git a/packages/blocks/src/microsheet-block/microsheet-block.ts b/packages/blocks/src/microsheet-block/microsheet-block.ts index 58ca105843a3..0c9aae60c070 100644 --- a/packages/blocks/src/microsheet-block/microsheet-block.ts +++ b/packages/blocks/src/microsheet-block/microsheet-block.ts @@ -8,7 +8,6 @@ import { popupTargetFromElement, } from '@blocksuite/affine-components/context-menu'; import { DragIndicator } from '@blocksuite/affine-components/drag-indicator'; -import { PeekViewProvider } from '@blocksuite/affine-components/peek'; import { toast } from '@blocksuite/affine-components/toast'; import { NOTE_SELECTOR } from '@blocksuite/affine-shared/consts'; import { Rect, Slot } from '@blocksuite/global/utils'; @@ -18,23 +17,20 @@ import { MoreHorizontalIcon, } from '@blocksuite/icons/lit'; import { - createRecordDetail, - createUniComponentFromWebComponent, DataView, dataViewCommonStyle, type DataViewExpose, type DataViewProps, - type DataViewSelection, - type DataViewWidget, - type DataViewWidgetProps, defineUniComponent, + type MicrosheetDataViewSelection, + type MicrosheetDataViewWidget, + type MicrosheetDataViewWidgetProps, MicrosheetSelection, renderUniLit, - uniMap, } from '@blocksuite/microsheet-data-view'; import { widgetPresets } from '@blocksuite/microsheet-data-view/widget-presets'; import { Slice } from '@blocksuite/store'; -import { computed, signal } from '@preact/signals-core'; +import { computed, type ReadonlySignal, signal } from '@preact/signals-core'; import { css, html, nothing, unsafeCSS } from 'lit'; import { query } from 'lit/decorators.js'; @@ -49,11 +45,8 @@ import { type RootService, } from '../root-block/index.js'; import { getDropResult } from '../root-block/widgets/drag-handle/utils.js'; -import { popSideDetail } from './components/layout.js'; import { HostContextKey } from './context/host-context.js'; import { MicrosheetBlockDataSource } from './data-source.js'; -import { BlockRenderer } from './detail-panel/block-renderer.js'; -import { NoteRenderer } from './detail-panel/note-renderer.js'; import { calculateLineNum, isInCellEnd, isInCellStart } from './utils.js'; export class MicrosheetBlockComponent extends CaptionedBlockComponent< @@ -78,10 +71,6 @@ export class MicrosheetBlockComponent extends CaptionedBlockComponent< visibility: visible; } - affine-microsheet affine-paragraph .affine-block-component { - // margin: 0 !important; - } - .microsheet-block-selected { background-color: var(--affine-hover-color); border-radius: 4px; @@ -143,7 +132,7 @@ export class MicrosheetBlockComponent extends CaptionedBlockComponent< items: [ menu.action({ prefix: DeleteIcon(), - class: 'delete-item', + class: { 'delete-item': true }, name: 'Delete Microsheet', select: () => { this.model.children.slice().forEach(block => { @@ -196,8 +185,8 @@ export class MicrosheetBlockComponent extends CaptionedBlockComponent< return this.std.getService('affine:page'); }; - headerWidget: DataViewWidget = defineUniComponent( - (props: DataViewWidgetProps) => { + headerWidget: MicrosheetDataViewWidget = defineUniComponent( + (props: MicrosheetDataViewWidgetProps) => { return html`
@@ -207,12 +196,8 @@ export class MicrosheetBlockComponent extends CaptionedBlockComponent< style="display:flex;align-items:center;justify-content: space-between;gap: 12px" class="microsheet-header-bar" > -
- ${renderUniLit(widgetPresets.viewBar, props)} -
${renderUniLit(this.toolsWidget, props)}
- ${renderUniLit(widgetPresets.filterBar, props)}
`; } @@ -257,9 +242,11 @@ export class MicrosheetBlockComponent extends CaptionedBlockComponent< return () => {}; }; - selectionUpdated = new Slot(); + selectionUpdated: Slot = new Slot< + MicrosheetDataViewSelection | undefined + >(); - setSelection = (selection: DataViewSelection | undefined) => { + setSelection = (selection: MicrosheetDataViewSelection | undefined) => { this.selection.setGroup( 'note', selection @@ -273,26 +260,22 @@ export class MicrosheetBlockComponent extends CaptionedBlockComponent< ); }; - toolsWidget: DataViewWidget = widgetPresets.createTools({ - table: [ - widgetPresets.tools.filter, - widgetPresets.tools.search, - widgetPresets.tools.viewOptions, - widgetPresets.tools.tableAddRow, - ], + toolsWidget: MicrosheetDataViewWidget = widgetPresets.createTools({ + table: [], }); - viewSelection$ = computed(() => { - const microsheetSelection = this.selection.value.find( - (selection): selection is MicrosheetSelection => { - if (selection.blockId !== this.blockId) { - return false; + viewSelection$: ReadonlySignal = + computed(() => { + const microsheetSelection = this.selection.value.find( + (selection): selection is MicrosheetSelection => { + if (selection.blockId !== this.blockId) { + return false; + } + return selection instanceof MicrosheetSelection; } - return selection instanceof MicrosheetSelection; - } - ); - return microsheetSelection?.viewSelection; - }); + ); + return microsheetSelection?.viewSelection; + }); virtualPadding$ = signal(0); @@ -311,6 +294,7 @@ export class MicrosheetBlockComponent extends CaptionedBlockComponent< get optionsConfig(): MicrosheetOptionsConfig { return { configure: (_model, options) => options, + // @ts-expect-error ...this.std.getConfig('affine:page')?.microsheetOptions, }; } @@ -492,7 +476,6 @@ export class MicrosheetBlockComponent extends CaptionedBlockComponent< } override renderBlock() { - const peekViewService = this.std.getOptional(PeekViewProvider); return html`
{ - const template = createRecordDetail({ - ...data, - detail: { - header: uniMap( - createUniComponentFromWebComponent(BlockRenderer), - props => ({ - ...props, - host: this.host, - }) - ), - note: uniMap( - createUniComponentFromWebComponent(NoteRenderer), - props => ({ - ...props, - model: this.model, - host: this.host, - }) - ), - }, - }); - if (peekViewService) { - return peekViewService.peek({ - target, - template, - }); - } else { - return popSideDetail(template); - } - }, - }, })}
`; diff --git a/packages/blocks/src/microsheet-block/microsheet-service.ts b/packages/blocks/src/microsheet-block/microsheet-service.ts index 7e8d74833b40..5fe29910af58 100644 --- a/packages/blocks/src/microsheet-block/microsheet-service.ts +++ b/packages/blocks/src/microsheet-block/microsheet-service.ts @@ -1,4 +1,4 @@ -import type { BlockModel, Doc } from '@blocksuite/store'; +import type { Doc } from '@blocksuite/store'; import { type MicrosheetBlockModel, @@ -36,12 +36,7 @@ export class MicrosheetBlockService extends BlockService { viewPresets = viewPresets; - initMicrosheetBlock( - doc: Doc, - model: BlockModel, - microsheetId: string, - viewType: string - ) { + initMicrosheetBlock(doc: Doc, microsheetId: string, viewType: string) { const blockModel = doc.getBlock(microsheetId)?.model as | MicrosheetBlockModel | undefined; diff --git a/packages/blocks/src/microsheet-block/properties/converts.ts b/packages/blocks/src/microsheet-block/properties/converts.ts deleted file mode 100644 index 55ea2d842de7..000000000000 --- a/packages/blocks/src/microsheet-block/properties/converts.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { clamp } from '@blocksuite/affine-shared/utils'; -import { - createPropertyConvert, - getTagColor, - type SelectTag, -} from '@blocksuite/microsheet-data-view'; -import { presetPropertyConverts } from '@blocksuite/microsheet-data-view/property-presets'; -import { propertyModelPresets } from '@blocksuite/microsheet-data-view/property-pure-presets'; -import { nanoid, Text } from '@blocksuite/store'; - -import { richTextColumnModelConfig } from './rich-text/define.js'; - -export const microsheetPropertyConverts = [ - ...presetPropertyConverts, - createPropertyConvert( - richTextColumnModelConfig, - propertyModelPresets.selectPropertyModelConfig, - (_property, cells) => { - const options: Record = {}; - 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 = {}; - 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 = ['yes', 'true']; - return { - property: {}, - cells: cells.map(v => - v && truthyValues.includes(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), - }; - } - ), -]; diff --git a/packages/blocks/src/microsheet-block/properties/index.ts b/packages/blocks/src/microsheet-block/properties/index.ts index d81b07078acc..e67b4cad66b9 100644 --- a/packages/blocks/src/microsheet-block/properties/index.ts +++ b/packages/blocks/src/microsheet-block/properties/index.ts @@ -1,37 +1,15 @@ import type { PropertyMetaConfig } from '@blocksuite/microsheet-data-view'; -import { propertyPresets } from '@blocksuite/microsheet-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 microsheetBlockColumns = { - checkboxColumnConfig: checkboxPropertyConfig, - dateColumnConfig: datePropertyConfig, - multiSelectColumnConfig: multiSelectPropertyConfig, - numberColumnConfig: numberPropertyConfig, - progressColumnConfig: progressPropertyConfig, - selectColumnConfig: selectPropertyConfig, - linkColumnConfig, richTextColumnConfig, }; export const microsheetBlockPropertyList = Object.values( microsheetBlockColumns ); -export const microsheetBlockHiddenColumns = [ - propertyPresets.imagePropertyConfig, - titleColumnConfig, -]; +export const microsheetBlockHiddenColumns = [titleColumnConfig]; const microsheetBlockAllColumns = [ ...microsheetBlockPropertyList, ...microsheetBlockHiddenColumns, diff --git a/packages/blocks/src/microsheet-block/utils.ts b/packages/blocks/src/microsheet-block/utils.ts index 174b568f8f7b..ea0ed571b97a 100644 --- a/packages/blocks/src/microsheet-block/utils.ts +++ b/packages/blocks/src/microsheet-block/utils.ts @@ -1,9 +1,9 @@ import type { - Cell, - Column, - ColumnUpdater, + MicrosheetCell as Cell, + MicrosheetColumn as Column, + MicrosheetColumnUpdater as ColumnUpdater, MicrosheetBlockModel, - ViewBasicDataType, + MicrosheetViewBasicDataType as ViewBasicDataType, } from '@blocksuite/affine-model'; import type { BlockStdScope, TextSelection } from '@blocksuite/block-std'; import type { BlockModel } from '@blocksuite/store'; @@ -208,13 +208,16 @@ export function updateCells( cells: Record ) { model.doc.transact(() => { - Object.entries(cells).forEach(([rowId, value]) => { + // @ts-expect-error + Object.entries(cells).forEach(([rowId, value, ref]) => { if (!model.cells[rowId]) { model.cells[rowId] = Object.create(null); } model.cells[rowId][columnId] = { columnId, value, + // @ts-expect-error + ref, }; }); }); diff --git a/packages/blocks/src/microsheet-data-view-block/block-meta/base.ts b/packages/blocks/src/microsheet-data-view-block/block-meta/base.ts index 9ff59d20ba35..162403f518ed 100644 --- a/packages/blocks/src/microsheet-data-view-block/block-meta/base.ts +++ b/packages/blocks/src/microsheet-data-view-block/block-meta/base.ts @@ -1,5 +1,5 @@ -import type { PropertyMetaConfig } from '@blocksuite/data-view'; import type { Disposable } from '@blocksuite/global/utils'; +import type { PropertyMetaConfig } from '@blocksuite/microsheet-data-view'; import type { Block, BlockModel } from '@blocksuite/store'; type PropertyMeta< diff --git a/packages/blocks/src/microsheet-data-view-block/block-meta/index.ts b/packages/blocks/src/microsheet-data-view-block/block-meta/index.ts index 6f0ad3b96af2..583105b7cf10 100644 --- a/packages/blocks/src/microsheet-data-view-block/block-meta/index.ts +++ b/packages/blocks/src/microsheet-data-view-block/block-meta/index.ts @@ -1,7 +1,3 @@ import type { BlockMeta } from './base.js'; -import { todoMeta } from './todo.js'; - -export const blockMetaMap = { - todo: todoMeta, -} satisfies Record; +export const blockMetaMap = {} satisfies Record; diff --git a/packages/blocks/src/microsheet-data-view-block/block-meta/todo.ts b/packages/blocks/src/microsheet-data-view-block/block-meta/todo.ts deleted file mode 100644 index 0a6f81165f60..000000000000 --- a/packages/blocks/src/microsheet-data-view-block/block-meta/todo.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { type ListBlockModel, ListBlockSchema } from '@blocksuite/affine-model'; -import { propertyPresets } from '@blocksuite/data-view/property-presets'; - -import { richTextColumnConfig } from '../../database-block/properties/rich-text/cell-renderer.js'; -import { createBlockMeta } from './base.js'; - -export const todoMeta = createBlockMeta({ - selector: block => { - if (block.flavour !== ListBlockSchema.model.flavour) { - return false; - } - - return (block.model as ListBlockModel).type === 'todo'; - }, -}); -todoMeta.addProperty({ - name: 'Content', - key: 'todo-title', - metaConfig: richTextColumnConfig, - get: block => block.text.yText, - set: (_block, _value) => { - // - }, - updated: (block, callback) => { - block.text?.yText.observe(callback); - return { - dispose: () => { - block.text?.yText.unobserve(callback); - }, - }; - }, -}); -todoMeta.addProperty({ - name: 'Checked', - key: 'todo-checked', - metaConfig: propertyPresets.checkboxPropertyConfig, - get: block => block.checked, - set: (block, value) => { - block.checked = value; - }, - updated: (block, callback) => { - return block.propsUpdated.on(({ key }) => { - if (key === 'checked') { - callback(); - } - }); - }, -}); - -todoMeta.addProperty({ - name: 'Source', - key: 'todo-source', - metaConfig: propertyPresets.textPropertyConfig, - get: block => block.doc.meta?.title ?? '', - updated: (block, callback) => { - return block.doc.collection.meta.docMetaUpdated.on(() => { - callback(); - }); - }, -}); diff --git a/packages/blocks/src/microsheet-data-view-block/columns/index.ts b/packages/blocks/src/microsheet-data-view-block/columns/index.ts index 3da211f70ab0..54765a9c0efb 100644 --- a/packages/blocks/src/microsheet-data-view-block/columns/index.ts +++ b/packages/blocks/src/microsheet-data-view-block/columns/index.ts @@ -1,17 +1,10 @@ -import type { PropertyMetaConfig } from '@blocksuite/data-view'; +import type { PropertyMetaConfig } from '@blocksuite/microsheet-data-view'; -import { propertyPresets } from '@blocksuite/data-view/property-presets'; +import { propertyPresets } from '@blocksuite/microsheet-data-view/property-presets'; import { richTextColumnConfig } from '../../database-block/properties/rich-text/cell-renderer.js'; -export const queryBlockColumns = [ - propertyPresets.datePropertyConfig, - propertyPresets.numberPropertyConfig, - propertyPresets.progressPropertyConfig, - propertyPresets.selectPropertyConfig, - propertyPresets.multiSelectPropertyConfig, - propertyPresets.checkboxPropertyConfig, -]; +export const queryBlockColumns = [propertyPresets.textPropertyConfig]; export const queryBlockHiddenColumns = [richTextColumnConfig]; const queryBlockAllColumns = [...queryBlockColumns, ...queryBlockHiddenColumns]; export const queryBlockAllColumnMap = Object.fromEntries( diff --git a/packages/blocks/src/microsheet-data-view-block/data-source.ts b/packages/blocks/src/microsheet-data-view-block/data-source.ts index 3e3c65e17d0a..ab7ea8d3e32a 100644 --- a/packages/blocks/src/microsheet-data-view-block/data-source.ts +++ b/packages/blocks/src/microsheet-data-view-block/data-source.ts @@ -6,12 +6,15 @@ import { insertPositionToIndex, type InsertToPosition, } from '@blocksuite/affine-shared/utils'; -import { DataSourceBase, type PropertyMetaConfig } from '@blocksuite/data-view'; -import { propertyPresets } from '@blocksuite/data-view/property-presets'; import { assertExists, Slot } from '@blocksuite/global/utils'; +import { + DataSourceBase, + type PropertyMetaConfig, +} from '@blocksuite/microsheet-data-view'; +import { propertyPresets } from '@blocksuite/microsheet-data-view/property-presets'; import type { BlockMeta } from './block-meta/base.js'; -import type { DataViewBlockModel } from './data-view-model.js'; +import type { MicrosheetDataViewBlockModel } from './data-view-model.js'; import { databaseBlockAllPropertyMap, @@ -64,7 +67,7 @@ export class BlockQueryDataSource extends DataSourceBase { constructor( private host: EditorHost, - private block: DataViewBlockModel, + private block: MicrosheetDataViewBlockModel, config: BlockQueryDataSourceConfig ) { super(); @@ -108,7 +111,12 @@ export class BlockQueryDataSource extends DataSourceBase { } cellRefGet(rowId: string, propertyId: string): string { - return this.getProperty(propertyId)?.get(this.block.model).ref ?? ''; + const block = this.blockMap.get(rowId); + if (block) { + // @ts-expect-error + return this.getProperty(propertyId)?.get(block.model)?.ref ?? ''; + } + return ''; } cellValueChange(rowId: string, propertyId: string, value: unknown): void { @@ -168,7 +176,7 @@ export class BlockQueryDataSource extends DataSourceBase { const doc = this.block.doc; doc.captureSync(); const column = databaseBlockAllPropertyMap[ - type ?? propertyPresets.multiSelectPropertyConfig.type + type ?? propertyPresets.textPropertyConfig.type ].create(this.newColumnName()); const id = doc.generateBlockId(); diff --git a/packages/blocks/src/microsheet-data-view-block/data-view-block.ts b/packages/blocks/src/microsheet-data-view-block/data-view-block.ts index 0dee0395f6b7..0464efd916ed 100644 --- a/packages/blocks/src/microsheet-data-view-block/data-view-block.ts +++ b/packages/blocks/src/microsheet-data-view-block/data-view-block.ts @@ -9,41 +9,34 @@ import { DeleteIcon, MoreHorizontalIcon, } from '@blocksuite/affine-components/icons'; -import { PeekViewProvider } from '@blocksuite/affine-components/peek'; import { RANGE_SYNC_EXCLUDE_ATTR } from '@blocksuite/block-std'; import { - createRecordDetail, - createUniComponentFromWebComponent, - DatabaseSelection, type DataSource, DataView, dataViewCommonStyle, type DataViewProps, - type DataViewSelection, - type DataViewWidget, - type DataViewWidgetProps, defineUniComponent, + type MicrosheetDataViewSelection, + type MicrosheetDataViewWidget, + type MicrosheetDataViewWidgetProps, + MicrosheetSelection, renderUniLit, - uniMap, -} from '@blocksuite/data-view'; -import { widgetPresets } from '@blocksuite/data-view/widget-presets'; +} from '@blocksuite/microsheet-data-view'; +import { widgetPresets } from '@blocksuite/microsheet-data-view/widget-presets'; import { Slice } from '@blocksuite/store'; -import { computed, signal } from '@preact/signals-core'; +import { computed, type ReadonlySignal, signal } from '@preact/signals-core'; import { css, nothing, unsafeCSS } from 'lit'; import { html } from 'lit/static-html.js'; import type { NoteBlockComponent } from '../note-block/index.js'; -import type { DataViewBlockModel } from './data-view-model.js'; +import type { MicrosheetDataViewBlockModel } from './data-view-model.js'; -import { BlockRenderer } from '../database-block/detail-panel/block-renderer.js'; -import { NoteRenderer } from '../database-block/detail-panel/note-renderer.js'; import { EdgelessRootBlockComponent, type RootService, } from '../root-block/index.js'; -import { BlockQueryDataSource } from './data-source.js'; -export class DataViewBlockComponent extends CaptionedBlockComponent { +export class MicrosheetDataViewBlockComponent extends CaptionedBlockComponent { static override styles = css` ${unsafeCSS(dataViewCommonStyle('affine-database'))} affine-database { @@ -112,7 +105,7 @@ export class DataViewBlockComponent extends CaptionedBlockComponent { this.model.children.slice().forEach(block => { @@ -152,8 +145,8 @@ export class DataViewBlockComponent extends CaptionedBlockComponent('affine:page'); }; - headerWidget: DataViewWidget = defineUniComponent( - (props: DataViewWidgetProps) => { + headerWidget: MicrosheetDataViewWidget = defineUniComponent( + (props: MicrosheetDataViewWidgetProps) => { return html`
@@ -164,35 +157,31 @@ export class DataViewBlockComponent extends CaptionedBlockComponent -
- ${renderUniLit(widgetPresets.viewBar, props)} -
${renderUniLit(this.toolsWidget, props)}
- ${renderUniLit(widgetPresets.filterBar, props)}
`; } ); - selection$ = computed(() => { - const databaseSelection = this.selection.value.find( - (selection): selection is DatabaseSelection => { + selection$: ReadonlySignal = computed(() => { + const microsheetSelection = this.selection.value.find( + (selection): selection is MicrosheetSelection => { if (selection.blockId !== this.blockId) { return false; } - return selection instanceof DatabaseSelection; + return selection instanceof MicrosheetSelection; } ); - return databaseSelection?.viewSelection; + return microsheetSelection?.viewSelection as MicrosheetDataViewSelection; }); - setSelection = (selection: DataViewSelection | undefined) => { + setSelection = (selection: MicrosheetDataViewSelection | undefined) => { this.selection.setGroup( 'note', selection ? [ - new DatabaseSelection({ + new MicrosheetSelection({ blockId: this.blockId, viewSelection: selection, }), @@ -201,22 +190,12 @@ export class DataViewBlockComponent extends CaptionedBlockComponent ${this.dataView.render({ @@ -259,35 +237,6 @@ export class DataViewBlockComponent extends CaptionedBlockComponent { - if (peekViewService) { - const template = createRecordDetail({ - ...data, - detail: { - header: uniMap( - createUniComponentFromWebComponent(BlockRenderer), - props => ({ - ...props, - host: this.host, - }) - ), - note: uniMap( - createUniComponentFromWebComponent(NoteRenderer), - props => ({ - ...props, - model: this.model, - host: this.host, - }) - ), - }, - }); - return peekViewService.peek({ target, template }); - } else { - return Promise.resolve(); - } - }, - }, })}
`; @@ -296,6 +245,6 @@ export class DataViewBlockComponent extends CaptionedBlockComponent>; }; -export class DataViewBlockModel extends BlockModel { +export class MicrosheetDataViewBlockModel extends BlockModel { constructor() { super(); } @@ -77,7 +77,7 @@ export class DataViewBlockModel extends BlockModel { } export const MicrosheetDataViewBlockSchema = defineBlockSchema({ - flavour: 'affine:data-view', + flavour: 'affine:microsheet-data-view', props: (): Props => ({ views: [], title: '', @@ -91,6 +91,6 @@ export const MicrosheetDataViewBlockSchema = defineBlockSchema({ children: ['affine:paragraph', 'affine:list'], }, toModel: () => { - return new DataViewBlockModel(); + return new MicrosheetDataViewBlockModel(); }, }); diff --git a/packages/blocks/src/microsheet-data-view-block/data-view-spec.ts b/packages/blocks/src/microsheet-data-view-block/data-view-spec.ts index 342e5f20138e..9c8c516a4634 100644 --- a/packages/blocks/src/microsheet-data-view-block/data-view-spec.ts +++ b/packages/blocks/src/microsheet-data-view-block/data-view-spec.ts @@ -5,10 +5,13 @@ import { } from '@blocksuite/block-std'; import { literal } from 'lit/static-html.js'; -import { DataViewBlockService } from './database-service.js'; +import { MicrosheetDataViewBlockService } from './microsheet-service.js'; -export const DataViewBlockSpec: ExtensionType[] = [ - FlavourExtension('affine:data-view'), - DataViewBlockService, - BlockViewExtension('affine:data-view', literal`affine-data-view`), +export const MicrosheetDataViewBlockSpec: ExtensionType[] = [ + FlavourExtension('affine:microsheet-data-view'), + MicrosheetDataViewBlockService, + BlockViewExtension( + 'affine:microsheet-data-view', + literal`affine-microsheet-data-view` + ), ]; diff --git a/packages/blocks/src/microsheet-data-view-block/database-service.ts b/packages/blocks/src/microsheet-data-view-block/database-service.ts deleted file mode 100644 index 1c21b4d87e64..000000000000 --- a/packages/blocks/src/microsheet-data-view-block/database-service.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { BlockService } from '@blocksuite/block-std'; -import { DatabaseSelection } from '@blocksuite/data-view'; - -import { DataViewBlockSchema } from './data-view-model.js'; - -export class DataViewBlockService extends BlockService { - static override readonly flavour = DataViewBlockSchema.model.flavour; - - override mounted(): void { - super.mounted(); - this.selectionManager.register(DatabaseSelection); - } -} diff --git a/packages/blocks/src/microsheet-data-view-block/index.ts b/packages/blocks/src/microsheet-data-view-block/index.ts index 1d022c0823c0..e0a82084d385 100644 --- a/packages/blocks/src/microsheet-data-view-block/index.ts +++ b/packages/blocks/src/microsheet-data-view-block/index.ts @@ -1,4 +1,4 @@ -import type { DataViewBlockModel } from './data-view-model.js'; +import type { MicrosheetDataViewBlockModel } from './data-view-model.js'; export * from './data-view-block.js'; export * from './data-view-model.js'; @@ -6,7 +6,7 @@ export * from './data-view-model.js'; declare global { namespace BlockSuite { interface BlockModels { - 'affine:data-view': DataViewBlockModel; + 'affine:microsheet-data-view': MicrosheetDataViewBlockModel; } } } diff --git a/packages/blocks/src/microsheet-data-view-block/microsheet-service.ts b/packages/blocks/src/microsheet-data-view-block/microsheet-service.ts new file mode 100644 index 000000000000..a99a6cfbafca --- /dev/null +++ b/packages/blocks/src/microsheet-data-view-block/microsheet-service.ts @@ -0,0 +1,14 @@ +import { BlockService } from '@blocksuite/block-std'; +import { MicrosheetSelection } from '@blocksuite/microsheet-data-view'; + +import { MicrosheetDataViewBlockSchema } from './data-view-model.js'; + +export class MicrosheetDataViewBlockService extends BlockService { + static override readonly flavour = + MicrosheetDataViewBlockSchema.model.flavour; + + override mounted(): void { + super.mounted(); + this.selectionManager.register(MicrosheetSelection); + } +} diff --git a/packages/blocks/src/microsheet-data-view-block/views/index.ts b/packages/blocks/src/microsheet-data-view-block/views/index.ts index ccc82798704e..59ac89911948 100644 --- a/packages/blocks/src/microsheet-data-view-block/views/index.ts +++ b/packages/blocks/src/microsheet-data-view-block/views/index.ts @@ -1,6 +1,6 @@ -import type { ViewMeta } from '@blocksuite/data-view'; +import type { ViewMeta } from '@blocksuite/microsheet-data-view'; -import { viewPresets } from '@blocksuite/data-view/view-presets'; +import { viewPresets } from '@blocksuite/microsheet-data-view/view-presets'; export const blockQueryViews: ViewMeta[] = [viewPresets.tableViewMeta]; diff --git a/packages/blocks/src/note-block/note-block.ts b/packages/blocks/src/note-block/note-block.ts index 8a23c70da5c5..515f6dbd6837 100644 --- a/packages/blocks/src/note-block/note-block.ts +++ b/packages/blocks/src/note-block/note-block.ts @@ -23,7 +23,7 @@ export class NoteBlockComponent extends BlockComponent< } override renderBlock() { - console.log(111, this.std.doc); + // console.log(111, this.std.doc); return html`
diff --git a/packages/blocks/src/root-block/clipboard/adapter.ts b/packages/blocks/src/root-block/clipboard/adapter.ts index 7daf532dc458..7401934cbafa 100644 --- a/packages/blocks/src/root-block/clipboard/adapter.ts +++ b/packages/blocks/src/root-block/clipboard/adapter.ts @@ -2,7 +2,6 @@ import type { CellBlockModel } from '@blocksuite/affine-model'; import type { BlockSnapshot, DocSnapshot, - DocSnapshot, FromBlockSnapshotPayload, FromBlockSnapshotResult, FromDocSnapshotPayload, @@ -115,6 +114,7 @@ export class MicrosheetAdapter extends BaseAdapter { ): | Promise> | FromSliceSnapshotResult { + // @ts-expect-error return payload; } @@ -141,9 +141,13 @@ export class MicrosheetAdapter extends BaseAdapter { ); const snapshot: SliceSnapshot = { type: 'slice', + // @ts-expect-error pageVersion: payload.pageVersion, + // @ts-expect-error workspaceVersion: payload.workspaceVersion, + // @ts-expect-error workspaceId: payload.workspaceId, + // @ts-expect-error pageId: payload.pageId, content: [microsheetSnapshotContent.toSnapshotContent()], }; @@ -265,17 +269,19 @@ class MicrosheetSnapshotContent { const row = this.addRow(); for (let j = 0; j < this.colCount; j++) { const cell = row.addCell(contentColumnIds[j]); + // @ts-expect-error cell.addChildren(this.getCellContent(i, j)); } } } toSnapshotContent() { + // @ts-expect-error return { type: 'block', id: nanoid(), flavour: 'affine:microsheet', - version: 3, + version: 1, props: this.getProps(), children: this.rows.map(row => row.toSnapshotContent()), } as SliceSnapshot['content'][number]; diff --git a/packages/blocks/src/root-block/widgets/slash-menu/config.ts b/packages/blocks/src/root-block/widgets/slash-menu/config.ts index 732c82b9429f..3ac9b6fdff4f 100644 --- a/packages/blocks/src/root-block/widgets/slash-menu/config.ts +++ b/packages/blocks/src/root-block/widgets/slash-menu/config.ts @@ -565,10 +565,8 @@ export const defaultSlashMenuConfig: SlashMenuConfig = { if (!service) return; service.initMicrosheetBlock( rootComponent.doc, - model, id, - viewPresets.tableViewMeta.type, - false + viewPresets.tableViewMeta.type ); tryRemoveEmptyLine(model); }, diff --git a/packages/blocks/src/row-block/row-block.ts b/packages/blocks/src/row-block/row-block.ts index 9335eef912a2..a238e2df391c 100644 --- a/packages/blocks/src/row-block/row-block.ts +++ b/packages/blocks/src/row-block/row-block.ts @@ -1,12 +1,12 @@ /// import type { RowBlockModel } from '@blocksuite/affine-model'; +import type { TableSingleView } from '@blocksuite/microsheet-data-view/view-presets'; import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption'; import { css, html } from 'lit'; import { property } from 'lit/decorators.js'; -import type { TableSingleView } from '../microsheet-block/data-view/view/presets/table/table-view-manager.js'; import type { RowBlockService } from './row-service.js'; export class RowBlockComponent extends CaptionedBlockComponent< @@ -50,6 +50,9 @@ export class RowBlockComponent extends CaptionedBlockComponent< @property({ attribute: false }) accessor view!: TableSingleView; + + @property({ attribute: false }) + override accessor widgets = {}; } declare global { diff --git a/packages/framework/block-std/src/range/range-binding.ts b/packages/framework/block-std/src/range/range-binding.ts index 7aacba204fcf..7566803c0591 100644 --- a/packages/framework/block-std/src/range/range-binding.ts +++ b/packages/framework/block-std/src/range/range-binding.ts @@ -169,6 +169,8 @@ export class RangeBinding { }; private _onNativeSelectionChanged = async () => { + console.log(111); + if (this.isComposing) return; await this.host.updateComplete; diff --git a/packages/framework/block-std/src/view/decorators/required.ts b/packages/framework/block-std/src/view/decorators/required.ts index 033f2be35ef7..436fc8d7abad 100644 --- a/packages/framework/block-std/src/view/decorators/required.ts +++ b/packages/framework/block-std/src/view/decorators/required.ts @@ -27,13 +27,6 @@ function validatePropTypes>( ) { for (const [propName, validator] of Object.entries(propTypes)) { const key = propName as keyof T; - if ( - (instance.flavour === 'affine:row' || - instance.flavour === 'affine:cell') && - propName === 'widgets' - ) { - continue; - } if (instance[key] === undefined) { throw new BlockSuiteError( ErrorCode.DefaultRuntimeError, diff --git a/packages/framework/global/src/exceptions/code.ts b/packages/framework/global/src/exceptions/code.ts index bcfa95276e44..170a10c6d0a2 100644 --- a/packages/framework/global/src/exceptions/code.ts +++ b/packages/framework/global/src/exceptions/code.ts @@ -18,6 +18,7 @@ export enum ErrorCode { GfxBlockElementError, MissingViewModelError, DatabaseBlockError, + MicrosheetBlockError, ParsingError, UserAbortError, ExecutionError, diff --git a/packages/framework/inline/src/__tests__/utils.ts b/packages/framework/inline/src/__tests__/utils.ts index b6b8255d7091..df2715c78262 100644 --- a/packages/framework/inline/src/__tests__/utils.ts +++ b/packages/framework/inline/src/__tests__/utils.ts @@ -3,7 +3,7 @@ import { expect, type Page } from '@playwright/test'; import type { DeltaInsert, InlineEditor, InlineRange } from '../index.js'; const defaultPlaygroundURL = new URL( - `http://localhost:${process.env.CI ? 4173 : 5173}/` + `http://localhost:${process.env.CI ? 4173 : 8001}/` ); export async function type(page: Page, content: string) { diff --git a/packages/playground/vite.config.ts b/packages/playground/vite.config.ts index 66d2bca55cb7..f24744d594c0 100644 --- a/packages/playground/vite.config.ts +++ b/packages/playground/vite.config.ts @@ -258,5 +258,8 @@ export default ({ mode }) => { }, }, }, + server: { + port: 8001, + }, }); }; diff --git a/tests/playwright.config.ts b/tests/playwright.config.ts index 728b25ad5bba..a28f1a34ce77 100644 --- a/tests/playwright.config.ts +++ b/tests/playwright.config.ts @@ -11,7 +11,7 @@ export default defineConfig({ snapshotPathTemplate: 'snapshots/{testFilePath}/{arg}{ext}', webServer: { command: process.env.CI ? 'yarn run -T preview' : 'yarn run -T dev', - port: process.env.CI ? 4173 : 5173, + port: process.env.CI ? 4173 : 8001, reuseExistingServer: !process.env.CI, env: { COVERAGE: process.env.COVERAGE ?? '', diff --git a/tests/utils/actions/misc.ts b/tests/utils/actions/misc.ts index 31913394b3a9..a0d6632d526f 100644 --- a/tests/utils/actions/misc.ts +++ b/tests/utils/actions/misc.ts @@ -36,7 +36,7 @@ declare global { } export const defaultPlaygroundURL = new URL( - `http://localhost:${process.env.CI ? 4173 : 5173}/starter/` + `http://localhost:${process.env.CI ? 4173 : 8001}/starter/` ); const NEXT_FRAME_TIMEOUT = 50; From 4a994dada27339729cb689efeacf77a07fdb0791 Mon Sep 17 00:00:00 2001 From: "caojiafu@cvte.com" Date: Thu, 14 Nov 2024 17:26:24 +0800 Subject: [PATCH 07/13] fix(examples): update yarn.lock --- yarn.lock | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/yarn.lock b/yarn.lock index 17f860bcdca1..654f37d299ad 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5056,6 +5056,13 @@ __metadata: languageName: node linkType: hard +"@types/sortablejs@npm:^1.15.8": + version: 1.15.8 + resolution: "@types/sortablejs@npm:1.15.8" + checksum: 10/aea58b08cf45f5e9633707a8df0df1212595c731bbdfd29805487138fdd0d8c51fa5c741999738a645c1e801d43a92ba0d3fb5b45625b52e247c56588aef6c55 + languageName: node + linkType: hard + "@types/statuses@npm:^2.0.4": version: 2.0.5 resolution: "@types/statuses@npm:2.0.5" @@ -13389,6 +13396,13 @@ __metadata: languageName: node linkType: hard +"sortablejs@npm:^1.15.2": + version: 1.15.3 + resolution: "sortablejs@npm:1.15.3" + checksum: 10/85d39a172ef47adedf273afa65daa8aefcbaafd43a5b5c480d8637add93033f5784da697d0d3545d9bb6e11fd71f1847f307ee26be452942f3785a683fd44bb5 + languageName: node + linkType: hard + "source-map-js@npm:^1.2.0, source-map-js@npm:^1.2.1": version: 1.2.1 resolution: "source-map-js@npm:1.2.1" From 5e8f1c217c7b1593aee3c2beac2cd80b7609c122 Mon Sep 17 00:00:00 2001 From: "caojiafu@cvte.com" Date: Thu, 14 Nov 2024 17:35:12 +0800 Subject: [PATCH 08/13] chore(examples): upgrade the theme's version to 0.17.0 --- yarn.lock | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/yarn.lock b/yarn.lock index 654f37d299ad..4fd6df775617 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4551,14 +4551,7 @@ __metadata: languageName: node linkType: hard -"@toeverything/theme@npm:^1.0.15": - version: 1.0.15 - resolution: "@toeverything/theme@npm:1.0.15" - checksum: 10/3588d127a7878706f8f7fb978a571a87193185618f6b6a0cc6b405d0552137c489ba90b9bc2e3b416b19da8872180b6033b1df761927689e45434f2da8988d64 - languageName: node - linkType: hard - -"@toeverything/theme@npm:^1.0.8": +"@toeverything/theme@npm:^1.0.15, @toeverything/theme@npm:^1.0.8": version: 1.0.17 resolution: "@toeverything/theme@npm:1.0.17" checksum: 10/77736bbae737539bbc1ef8e21a7c69a7146c463b7a3d8cdae9c523c4a53a6ac5b7549929efb3912e299f2acb56f0c797b8a077073d2b0826cd244776d4322010 From 7b96258c7ce567d5dc2dce37bcd24004f3dcf3d0 Mon Sep 17 00:00:00 2001 From: "caojiafu@cvte.com" Date: Thu, 14 Nov 2024 17:45:53 +0800 Subject: [PATCH 09/13] fix(blocks): remove the console statement --- packages/blocks/src/note-block/note-block.ts | 1 - packages/framework/block-std/src/range/range-binding.ts | 2 -- 2 files changed, 3 deletions(-) diff --git a/packages/blocks/src/note-block/note-block.ts b/packages/blocks/src/note-block/note-block.ts index 515f6dbd6837..2e686fe563a3 100644 --- a/packages/blocks/src/note-block/note-block.ts +++ b/packages/blocks/src/note-block/note-block.ts @@ -23,7 +23,6 @@ export class NoteBlockComponent extends BlockComponent< } override renderBlock() { - // console.log(111, this.std.doc); return html`
diff --git a/packages/framework/block-std/src/range/range-binding.ts b/packages/framework/block-std/src/range/range-binding.ts index 7566803c0591..7aacba204fcf 100644 --- a/packages/framework/block-std/src/range/range-binding.ts +++ b/packages/framework/block-std/src/range/range-binding.ts @@ -169,8 +169,6 @@ export class RangeBinding { }; private _onNativeSelectionChanged = async () => { - console.log(111); - if (this.isComposing) return; await this.host.updateComplete; From 91a9175519d4b95cb134ead82e7342991f0b5995 Mon Sep 17 00:00:00 2001 From: "caojiafu@cvte.com" Date: Fri, 15 Nov 2024 18:13:33 +0800 Subject: [PATCH 10/13] fix(blocks): cell-block remove the selectBlock --- .../src/core/common/selection-schema.ts | 57 ------------------- .../blocks/src/cell-block/cell-service.ts | 18 +++--- packages/framework/store/src/adapter/base.ts | 2 +- 3 files changed, 10 insertions(+), 67 deletions(-) diff --git a/packages/affine/data-view/src/core/common/selection-schema.ts b/packages/affine/data-view/src/core/common/selection-schema.ts index eff1c5ebd2ef..db6ee5bd567e 100644 --- a/packages/affine/data-view/src/core/common/selection-schema.ts +++ b/packages/affine/data-view/src/core/common/selection-schema.ts @@ -122,63 +122,6 @@ export class DatabaseSelection extends BaseSelection { } } -export class MicrosheetSelection extends BaseSelection { - static override group = 'note'; - - static override type = 'microsheet'; - - readonly viewSelection: DataViewSelection; - - get viewId() { - return this.viewSelection.viewId; - } - - constructor({ - blockId, - viewSelection, - }: { - blockId: string; - viewSelection: DataViewSelection; - }) { - super({ - blockId, - }); - - this.viewSelection = viewSelection; - } - - static override fromJSON(json: Record): DatabaseSelection { - DatabaseSelectionSchema.parse(json); - return new DatabaseSelection({ - blockId: json.blockId as string, - viewSelection: json.viewSelection as DataViewSelection, - }); - } - - override equals(other: BaseSelection): boolean { - if (!(other instanceof DatabaseSelection)) { - return false; - } - return this.blockId === other.blockId; - } - - getSelection( - type: T - ): GetDataViewSelection | undefined { - return this.viewSelection.type === type - ? (this.viewSelection as GetDataViewSelection) - : undefined; - } - - override toJSON(): Record { - return { - type: 'microsheet', - blockId: this.blockId, - viewSelection: this.viewSelection, - }; - } -} - declare global { namespace BlockSuite { interface Selection { diff --git a/packages/blocks/src/cell-block/cell-service.ts b/packages/blocks/src/cell-block/cell-service.ts index 243aa77bf9a9..1160d310d52b 100644 --- a/packages/blocks/src/cell-block/cell-service.ts +++ b/packages/blocks/src/cell-block/cell-service.ts @@ -1,5 +1,5 @@ import { CellBlockSchema } from '@blocksuite/affine-model'; -import { BlockService, type Command } from '@blocksuite/block-std'; +import { BlockService } from '@blocksuite/block-std'; export class CellBlockService extends BlockService { static override readonly flavour = CellBlockSchema.model.flavour; @@ -7,18 +7,18 @@ export class CellBlockService extends BlockService { override mounted(): void { super.mounted(); - this.std.command.add('selectBlock', selectBlock); + // this.std.command.add('selectBlock', selectBlock); } } -export const selectBlock: Command<'focusBlock'> = (ctx, next) => { - const { focusBlock } = ctx; - if (!focusBlock) { - return; - } +// export const selectBlock: Command<'focusBlock'> = (ctx, next) => { +// const { focusBlock } = ctx; +// if (!focusBlock) { +// return; +// } - return next(); -}; +// return next(); +// }; declare global { namespace BlockSuite { diff --git a/packages/framework/store/src/adapter/base.ts b/packages/framework/store/src/adapter/base.ts index a6fd39d355b2..7b1a81e55a85 100644 --- a/packages/framework/store/src/adapter/base.ts +++ b/packages/framework/store/src/adapter/base.ts @@ -119,7 +119,7 @@ export abstract class BaseAdapter { try { const sliceSnapshot = await this.job.sliceToSnapshot(slice); if (!sliceSnapshot) return; - // wrapFakeNote(sliceSnapshot); + wrapFakeNote(sliceSnapshot); return await this.fromSliceSnapshot({ snapshot: sliceSnapshot, assets: this.job.assetsManager, From c4ce4932a2e518d8189f063ab884c5eec52f5674 Mon Sep 17 00:00:00 2001 From: "caojiafu@cvte.com" Date: Mon, 18 Nov 2024 14:02:43 +0800 Subject: [PATCH 11/13] fix(blocks): micorsheet-block : change the alias --- .../root-block/widgets/slash-menu/config.ts | 49 ++++++++++--------- 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/packages/blocks/src/root-block/widgets/slash-menu/config.ts b/packages/blocks/src/root-block/widgets/slash-menu/config.ts index 5121c732a1a4..0f7acadd79b9 100644 --- a/packages/blocks/src/root-block/widgets/slash-menu/config.ts +++ b/packages/blocks/src/root-block/widgets/slash-menu/config.ts @@ -538,30 +538,7 @@ export const defaultSlashMenuConfig: SlashMenuConfig = { // --------------------------------------------------------- { groupName: 'Database' }, - { - name: 'Table', - description: 'Display items in a table format.', - alias: ['table'], - icon: DatabaseTableViewIcon20, - tooltip: slashMenuToolTips['Table'], - showWhen: ({ model }) => - model.doc.schema.flavourSchemaMap.has('affine:microsheet') && - !insideEdgelessText(model), - action: ({ rootComponent, model }) => { - const id = createMicrosheetBlockInNextLine(model); - if (!id) { - return; - } - const service = rootComponent.std.getService('affine:microsheet'); - if (!service) return; - service.initMicrosheetBlock( - rootComponent.doc, - id, - viewPresets.tableViewMeta.type - ); - tryRemoveEmptyLine(model); - }, - }, + { name: 'Table View', description: 'Display items in a table format.', @@ -614,6 +591,30 @@ export const defaultSlashMenuConfig: SlashMenuConfig = { tryRemoveEmptyLine(model); }, }, + { + name: 'Micro Sheet', + description: 'Display items in a table format.', + alias: ['table'], + icon: DatabaseTableViewIcon20, + tooltip: slashMenuToolTips['Table'], + showWhen: ({ model }) => + model.doc.schema.flavourSchemaMap.has('affine:microsheet') && + !insideEdgelessText(model), + action: ({ rootComponent, model }) => { + const id = createMicrosheetBlockInNextLine(model); + if (!id) { + return; + } + const service = rootComponent.std.getService('affine:microsheet'); + if (!service) return; + service.initMicrosheetBlock( + rootComponent.doc, + id, + viewPresets.tableViewMeta.type + ); + tryRemoveEmptyLine(model); + }, + }, { name: 'Kanban View', description: 'Visualize data in a dashboard.', From d519d98a518b74184cfa798c8e6e0f510c3d7810 Mon Sep 17 00:00:00 2001 From: "caojiafu@cvte.com" Date: Mon, 18 Nov 2024 14:47:09 +0800 Subject: [PATCH 12/13] =?UTF-8?q?fix(blocks):=20e2e-test:=20native?= =?UTF-8?q?=E3=80=81slash-menu=20fit=20the=20microsheet-block?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../root-block/widgets/slash-menu/config.ts | 46 +++++++++---------- tests/selection/native.spec.ts | 1 + tests/slash-menu.spec.ts | 5 +- 3 files changed, 28 insertions(+), 24 deletions(-) diff --git a/packages/blocks/src/root-block/widgets/slash-menu/config.ts b/packages/blocks/src/root-block/widgets/slash-menu/config.ts index 0f7acadd79b9..ad787520b976 100644 --- a/packages/blocks/src/root-block/widgets/slash-menu/config.ts +++ b/packages/blocks/src/root-block/widgets/slash-menu/config.ts @@ -592,9 +592,30 @@ export const defaultSlashMenuConfig: SlashMenuConfig = { }, }, { - name: 'Micro Sheet', + name: 'Kanban View', + description: 'Visualize data in a dashboard.', + alias: ['database'], + icon: DatabaseKanbanViewIcon20, + tooltip: slashMenuToolTips['Kanban View'], + showWhen: ({ model }) => + model.doc.schema.flavourSchemaMap.has('affine:database') && + !insideEdgelessText(model), + action: ({ rootComponent }) => { + rootComponent.std.command + .chain() + .getSelectedModels() + .insertDatabaseBlock({ + viewType: viewPresets.kanbanViewMeta.type, + place: 'after', + removeEmptyLine: true, + }) + .run(); + }, + }, + { + name: 'Normal Table', description: 'Display items in a table format.', - alias: ['table'], + alias: ['database'], icon: DatabaseTableViewIcon20, tooltip: slashMenuToolTips['Table'], showWhen: ({ model }) => @@ -615,27 +636,6 @@ export const defaultSlashMenuConfig: SlashMenuConfig = { tryRemoveEmptyLine(model); }, }, - { - name: 'Kanban View', - description: 'Visualize data in a dashboard.', - alias: ['database'], - icon: DatabaseKanbanViewIcon20, - tooltip: slashMenuToolTips['Kanban View'], - showWhen: ({ model }) => - model.doc.schema.flavourSchemaMap.has('affine:database') && - !insideEdgelessText(model), - action: ({ rootComponent }) => { - rootComponent.std.command - .chain() - .getSelectedModels() - .insertDatabaseBlock({ - viewType: viewPresets.kanbanViewMeta.type, - place: 'after', - removeEmptyLine: true, - }) - .run(); - }, - }, // --------------------------------------------------------- { groupName: 'Actions' }, diff --git a/tests/selection/native.spec.ts b/tests/selection/native.spec.ts index 815396210b15..318edd6d9915 100644 --- a/tests/selection/native.spec.ts +++ b/tests/selection/native.spec.ts @@ -288,6 +288,7 @@ test('cursor move to up and down with children block', async ({ page }) => { await page.keyboard.press('ArrowLeft'); } await page.keyboard.press('ArrowUp'); + await page.waitForTimeout(0); const indexTwo = await getInlineSelectionIndex(page); const textTwo = await getInlineSelectionText(page); expect(textTwo).toBe('arrow down test 1'); diff --git a/tests/slash-menu.spec.ts b/tests/slash-menu.spec.ts index a970aa3b1dda..7927768a6fcc 100644 --- a/tests/slash-menu.spec.ts +++ b/tests/slash-menu.spec.ts @@ -610,9 +610,12 @@ test.describe('slash search', () => { const slashItems = slashMenu.locator('icon-button'); await type(page, 'database'); - await expect(slashItems).toHaveCount(2); + await expect(slashItems).toHaveCount(3); await expect(slashItems.nth(0).locator('.text')).toHaveText(['Table View']); await expect(slashItems.nth(1).locator('.text')).toHaveText([ + 'Normal Table', + ]); + await expect(slashItems.nth(2).locator('.text')).toHaveText([ 'Kanban View', ]); await type(page, 'v'); From cc15d80361203ba87ce01b2e1bb1e4a434c29a15 Mon Sep 17 00:00:00 2001 From: "caojiafu@cvte.com" Date: Mon, 18 Nov 2024 14:56:42 +0800 Subject: [PATCH 13/13] fix(blocks): cell-block remove the command of selectBlock --- .../blocks/src/cell-block/cell-service.ts | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/packages/blocks/src/cell-block/cell-service.ts b/packages/blocks/src/cell-block/cell-service.ts index 1160d310d52b..784516768f39 100644 --- a/packages/blocks/src/cell-block/cell-service.ts +++ b/packages/blocks/src/cell-block/cell-service.ts @@ -6,24 +6,5 @@ export class CellBlockService extends BlockService { override mounted(): void { super.mounted(); - - // this.std.command.add('selectBlock', selectBlock); - } -} - -// export const selectBlock: Command<'focusBlock'> = (ctx, next) => { -// const { focusBlock } = ctx; -// if (!focusBlock) { -// return; -// } - -// return next(); -// }; - -declare global { - namespace BlockSuite { - interface Commands { - selectBlock: typeof selectBlock; - } } }