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/block-paragraph/src/paragraph-block.ts b/packages/affine/block-paragraph/src/paragraph-block.ts index f33d656e2923..ef6030c66047 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/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..710ae963516d --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/ast.ts @@ -0,0 +1,33 @@ +import type { TType } from '../logical/typesystem.js'; +import type { UniComponent } from '../utils/uni-component/uni-component.js'; + +export type Variable = { + name: string; + type: TType; + id: string; + icon?: UniComponent; +}; +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 GroupExp = { + left: VariableOrProperty; + type: 'asc' | 'desc'; +}; + +export type GroupList = GroupExp[]; 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..e46e450f5ced --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/data-source/base.ts @@ -0,0 +1,215 @@ +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 { 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; + + 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; + + 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; + + 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 properties$: ReadonlySignal; + + abstract propertyMetas: PropertyMetaConfig[]; + + abstract readonly$: ReadonlySignal; + + abstract rows$: ReadonlySignal; + + abstract viewDataList$: ReadonlySignal; + + abstract viewManager: ViewManager; + + abstract viewMetas: ViewMeta[]; + + abstract cellRefGet(rowId: string, propertyId: string): unknown; + + 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 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..973534a80bb5 --- /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': true }, + }), + ], + }), + ], + }, + }); + }; + + @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..8f117165f7ea --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/detail/selection.ts @@ -0,0 +1,136 @@ +import type { RecordDetail } from './detail.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; + } + this.getFocusCellContainer(selection)?.nextElementSibling; + } + + 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; + } +} 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..d9889bf47b4b --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/group-by/define.ts @@ -0,0 +1,35 @@ +import type { GroupByConfig } from './types.js'; + +import { tString } from '../../logical/data-type.js'; +import { MatcherCreator } from '../../logical/matcher.js'; +import { createUniComponentFromWebComponent } from '../../utils/uni-component/uni-component.js'; +import { StringGroupView } from './renderer/string-group.js'; + +const groupByMatcherCreator = new MatcherCreator(); +const ungroups = { + key: 'Ungroups', + value: null, +}; +export const groupByMatchers = [ + 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), + }), +]; 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/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..4bddfc97b103 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/group-by/setting.ts @@ -0,0 +1,297 @@ +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 { TableViewData } from '../../../view-presets/index.js'; +import type { SingleView } from '../../view-manager/single-view.js'; +import type { GroupRenderProps } from './types.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; +} + +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.changeGroup(id); + ops?.onSelect?.(id); + } + }, + }); + }), + menu.group({ + items: [ + menu.action({ + prefix: DeleteIcon(), + hide: () => view.data$.value?.groupBy == null, + class: { 'delete-item': true }, + 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': true }, + 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..906b6f92124a --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/literal/define.ts @@ -0,0 +1,29 @@ +import { menu, popMenu } from '@blocksuite/affine-components/context-menu'; + +import type { LiteralData } from './types.js'; + +import { tString } from '../../logical/data-type.js'; +import { MatcherCreator } from '../../logical/matcher.js'; +import { createUniComponentFromWebComponent } from '../../utils/uni-component/uni-component.js'; +import { StringLiteral } from './renderer/literal-element.js'; + +const literalMatcherCreator = new MatcherCreator(); +export const literalMatchers = [ + 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); + }, + }), + ], + }, + }); + }, + }), +]; 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/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/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..c5812a885614 --- /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 { + 'microsheet-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..31e2c0b809e8 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/ref/ref.ts @@ -0,0 +1,108 @@ +import { + menu, + popFilterableSimpleMenu, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { css, html } from 'lit'; +import { property } from 'lit/decorators.js'; + +import type { Variable, VariableOrProperty } from '../ast.js'; + +import { renderUniLit } from '../../utils/uni-component/uni-component.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; + } +} 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..ce96a49e8d3e --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/common/selection.ts @@ -0,0 +1,109 @@ +import { BaseSelection, SelectionExtension } from '@blocksuite/block-std'; +import { z } from 'zod'; + +import type { + GetMicrosheetDataViewSelection, + MicrosheetDataViewSelection, +} 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 MicrosheetSelectionSchema = z.object({ + blockId: z.string(), + viewSelection: TableViewSelectionSchema, +}); + +export class MicrosheetSelection extends BaseSelection { + static override group = 'note'; + + static override type = 'microsheet'; + + readonly viewSelection: MicrosheetDataViewSelection; + + get viewId() { + return this.viewSelection.viewId; + } + + constructor({ + blockId, + viewSelection, + }: { + blockId: string; + viewSelection: MicrosheetDataViewSelection; + }) { + 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 MicrosheetDataViewSelection, + }); + } + + override equals(other: BaseSelection): boolean { + if (!(other instanceof MicrosheetSelection)) { + return false; + } + return this.blockId === other.blockId; + } + + getSelection( + type: T + ): GetMicrosheetDataViewSelection | undefined { + return this.viewSelection.type === type + ? (this.viewSelection as GetMicrosheetDataViewSelection) + : 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/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..959230e16945 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/data-view.ts @@ -0,0 +1,193 @@ +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 { + MicrosheetDataViewSelection, + MicrosheetDataViewSelectionState, +} 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?: MicrosheetDataViewSelectionState) => void; + bindHotkey: DataViewProps['bindHotkey']; + handleEvent: DataViewProps['handleEvent']; +}; + +export type DataViewRendererConfig = { + bindHotkey: DataViewProps['bindHotkey']; + handleEvent: DataViewProps['handleEvent']; + virtualPadding$: DataViewProps['virtualPadding$']; + selection$: ReadonlySignal; + setSelection: (selection: MicrosheetDataViewSelection | undefined) => void; + dataSource: DataSource; + 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; + 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(); + }; + + 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..4fc71153d8bb --- /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 { MicrosheetDataViewSelection } 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..eedb423a72ff --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/logical/data-type.ts @@ -0,0 +1,10 @@ +import { typesystem } from './typesystem.js'; + +export const tString = typesystem.defineData<{ value: string }>({ + name: 'String', + supers: [], +}); +export const tRichText = typesystem.defineData<{ value: string }>({ + name: 'RichText', + supers: [tString], +}); 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/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..fa804cc79280 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/types.ts @@ -0,0 +1,25 @@ +import type { TableViewSelectionWithType } from '../view-presets/table/types.js'; + +export type MicrosheetDataViewSelection = TableViewSelectionWithType; +export type GetMicrosheetDataViewSelection< + K extends MicrosheetDataViewSelection['type'], + T = MicrosheetDataViewSelection, +> = T extends { + type: K; +} + ? T + : never; +export type MicrosheetDataViewSelectionState = + | MicrosheetDataViewSelection + | 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..09090f1e8c10 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/utils/index.ts @@ -0,0 +1,2 @@ +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/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..7919f0fa38d6 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/view-manager/property.ts @@ -0,0 +1,165 @@ +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 + ); + } + + 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..ec5bbfc1a9ab --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/view-manager/single-view.ts @@ -0,0 +1,384 @@ +import type { InsertToPosition } from '@blocksuite/affine-shared/utils'; + +import { computed, type ReadonlySignal, signal } from '@preact/signals-core'; + +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 { 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 vars$: 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; + + 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 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.dataSource.rows$.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 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 + ) {} + + 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); + } + + 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); + } + + 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..236e61e0eeed --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/view-manager/view-manager.ts @@ -0,0 +1,103 @@ +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; +} + +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; + } + + 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..244a57e490a6 --- /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 { 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 MicrosheetDataViewSelection = MicrosheetDataViewSelection, +> 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..b8429cf7f342 --- /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 { MicrosheetDataViewSelection } from '../types.js'; +import type { SingleView } from '../view-manager/index.js'; +import type { MicrosheetDataViewWidget } from '../widget/index.js'; + +export interface DataViewProps< + T extends SingleView = SingleView, + Selection extends MicrosheetDataViewSelection = MicrosheetDataViewSelection, +> { + dataViewEle: DataViewRenderer; + + headerWidget?: MicrosheetDataViewWidget; + + 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?(): MicrosheetDataViewSelection | 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..dfadc3ca6d0a --- /dev/null +++ b/packages/affine/microsheet-data-view/src/core/widget/types.ts @@ -0,0 +1,10 @@ +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 MicrosheetDataViewWidgetProps = { + view: SingleView; + viewMethods: DataViewExpose; +}; +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 new file mode 100644 index 000000000000..6eb1925a9024 --- /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 { MicrosheetDataViewWidgetProps } from './types.js'; + +export class WidgetBase + extends SignalWatcher(WithDisposable(ShadowlessElement)) + implements MicrosheetDataViewWidgetProps +{ + 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..9be84ff18b4d --- /dev/null +++ b/packages/affine/microsheet-data-view/src/effects.ts @@ -0,0 +1,96 @@ +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 { + NumberLiteral, + StringLiteral, +} from './core/common/literal/renderer/literal-element.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, UniAnyRender, UniLit } from './core/index.js'; +import { AnyRender } from './core/utils/uni-component/render-template.js'; +import { + TextCell, + TextCellEditing, +} from './property-presets/text/cell-renderer.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'; +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 { 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 { DataViewHeaderTools } from './widget-presets/tools/tools-renderer.js'; + +export function effects() { + customElements.define( + 'microsheet-data-view-header-tools', + DataViewHeaderTools + ); + customElements.define( + 'affine-microsheet-cell-container', + MicrosheetCellContainer + ); + customElements.define( + 'affine-microsheet-data-view-renderer', + DataViewRenderer + ); + customElements.define('microsheet-any-render', AnyRender); + customElements.define( + 'microsheet-data-view-properties-setting', + DataViewPropertiesSettingView + ); + customElements.define('affine-microsheet-text-cell', TextCell); + customElements.define('affine-microsheet-text-cell-editing', TextCellEditing); + 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('affine-microsheet-lit-icon', AffineLitIcon); + customElements.define( + 'microsheet-data-view-literal-number-view', + NumberLiteral + ); + customElements.define( + 'microsheet-data-view-literal-string-view', + StringLiteral + ); + customElements.define('affine-microsheet-table', DataViewTable); + customElements.define('microsheet-uni-lit', UniLit); + customElements.define('microsheet-uni-any-render', UniAnyRender); + customElements.define( + 'microsheet-data-view-table-selection', + SelectionElement + ); + customElements.define('microsheet-variable-ref-view', VariableRefView); + customElements.define( + 'affine-microsheet-data-view-record-detail', + RecordDetail + ); + customElements.define( + 'affine-microsheet-column-header', + MicrosheetColumnHeader + ); + 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); +} 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/index.ts b/packages/affine/microsheet-data-view/src/property-presets/index.ts new file mode 100644 index 000000000000..f109b1d7207c --- /dev/null +++ b/packages/affine/microsheet-data-view/src/property-presets/index.ts @@ -0,0 +1,5 @@ +import { textPropertyConfig } from './text/cell-renderer.js'; + +export const propertyPresets = { + textPropertyConfig, +}; 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..069db2290174 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/property-presets/pure-index.ts @@ -0,0 +1,5 @@ +import { textPropertyModelConfig } from './text/define.js'; + +export const propertyModelPresets = { + textPropertyModelConfig, +}; 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/index.ts b/packages/affine/microsheet-data-view/src/view-presets/index.ts new file mode 100644 index 000000000000..211281f14539 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/index.ts @@ -0,0 +1,7 @@ +import { tableViewMeta } from './table/index.js'; + +export * from './table/index.js'; + +export const viewPresets = { + tableViewMeta: tableViewMeta, +}; 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..d4c3c1c46e32 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/table/cell.ts @@ -0,0 +1,191 @@ +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'; +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 { 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'; + +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; + padding: 2px 8px; + } + + 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 = (focusTo?: 'start' | 'end') => { + if (this.view.readonly$.value) { + return; + } + const selectionView = this.selectionView; + if (selectionView) { + if (selectionView) { + this.selectionView.focus = { + rowIndex: this.rowIndex, + columnIndex: this.columnIndex, + }; + } + + assertExists(this.refModel); + + const focus = () => { + if (focusTo && this.std) { + const richTexts = this.querySelectorAll('rich-text'); + + if (richTexts.length) { + if (focusTo === 'start') { + (richTexts[0] as RichText).inlineEditor?.focusStart(); + } else { + richTexts[richTexts.length - 1].inlineEditor?.focusEnd(); + } + } + } + }; + + if (this.refModel.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(); + } + } + }; + + get cell(): DataViewCellLifeCycle | undefined { + return this._cell.value; + } + + private get groupKey() { + return ( + this.closest('affine-microsheet-data-view-table-group') as TableGroup + )?.group?.key; + } + + 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; + } + + get table() { + const table = this.closest('affine-microsheet-table'); + assertExists(table); + return table; + } + + override connectedCallback() { + super.connectedCallback(); + 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('end'); + } else { + this.selectCurrentCell(); + } + // this.selectCurrentCell(); + } + }); + } + + 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; + + assertExists(this.refModel); + return html``; + } + + @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/consts.ts b/packages/affine/microsheet-data-view/src/view-presets/table/consts.ts new file mode 100644 index 000000000000..f42e53e0a303 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/table/consts.ts @@ -0,0 +1,21 @@ +/** 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 = 10; +/** column title height */ +export const DEFAULT_ADD_BUTTON_WIDTH = 40; +export const LEFT_TOOL_BAR_WIDTH = 24; +export const STATS_BAR_HEIGHT = 34; + +/** microseet column default width */ +export const DEFAULT_MICROSHEET_COLUMN_WIDTH = 100; +/** microseet column min width */ +export const DEFAULT_MICROSHEET_COLUMN_MIN_WIDTH = 100; +/** microseet column title height */ +export const DEFAULT_MICROSHEET_COLUMN_TITLE_HEIGHT = 10; +/** microseet column title height */ +export const DEFAULT_MICROSHEET_ADD_BUTTON_WIDTH = 40; +export const MICROSHEET_LEFT_TOOL_BAR_WIDTH = 24; +export const MICROSHEET_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..291cd233285c --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/table/controller/clipboard.ts @@ -0,0 +1,352 @@ +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 { + TableAreaSelection, + TableRowSelection, + type TableViewSelection, + type TableViewSelectionWithType, +} from '../types.js'; + +const BLOCKSUITE_MICROSHEET_TABLE = 'blocksuite/microsheet'; +const TEXT = 'text/plain'; + +export class TableClipboardController implements ReactiveController { + private _onCopy = async ( + tableSelection: TableViewSelectionWithType, + isCut = false + ) => { + const table = this.host; + + const area = getSelectedAreaValues(tableSelection, table); + if (!area) { + return; + } + + const promiseArr: Promise[] = []; + area.forEach(row => { + row.forEach(cell => { + promiseArr.push( + (async () => { + const cellContainerModel = this.std.doc.getBlockById( + cell.ref as string + ); + if (cellContainerModel) { + const slice = Slice.fromModels( + this.std.doc, + cellContainerModel.children + ); + // @ts-expect-error + const item = await this.std.clipboard._getClipboardItem( + slice, + 'BLOCKSUITE/SNAPSHOT' + ); + cell['cellContainerSlice'] = item as string; + if (isCut) { + const children = cellContainerModel.children; + children.forEach(b => this.std.doc.deleteBlock(b)); + this.std.doc.addBlock( + 'affine:paragraph', + {}, + cellContainerModel.id + ); + } + } + })() + ); + }); + }); + await Promise.all(promiseArr); + this.std.clipboard + .writeToClipboard(items => { + return { + ...items, + [TEXT]: 'microsheet-copy-block', + [BLOCKSUITE_MICROSHEET_TABLE]: JSON.stringify(area), + }; + }) + .then(() => { + const count = area.flatMap(row => row).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) + .then() + .catch(() => {}); + }; + + 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 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]; + if (!copiedValues) return; + 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; + 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; + }; + + 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); + } + + 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) { + return; + } + this._onCopy(tableSelection).catch(console.error); + } + + cut() { + const tableSelection = this.host.selectionController.selection; + if (!tableSelection) { + return; + } + this._onCopy(tableSelection, true).catch(err => console.log(err)); + } + + hostConnected() { + this.host.disposables.add( + this.props.handleEvent('copy', _ctx => { + const tableSelection = this.host.selectionController.selection; + if (!tableSelection) return false; + + this._onCopy(tableSelection).catch(console.error); + 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 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 +): 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[]; +}[]; + +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-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..28f1dd12c818 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/table/controller/drag.ts @@ -0,0 +1,206 @@ +// related component + +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'; + +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; + + // debugger + 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('microsheet-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 as TableRow).groupKey, + position: { + id: (row as TableRow).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('.microsheet-data-view-table-view-drag-handler') + ) { + event.preventDefault(); + const row = target.closest('microsheet-data-view-table-row'); + if (row) { + getSelection()?.removeAllRanges(); + this.dragStart(row as TableRow, event); + } + return true; + } + return false; + }) + ); + } +} + +const createDragPreview = (row: TableRow, x: number, y: number) => { + const div = document.createElement('div'); + div.append(row.cloneNode(true)); + div.className = 'with-data-view-css-variable'; + div.style.opacity = '0.8'; + 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..1b9158a005f2 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/table/controller/hotkeys.ts @@ -0,0 +1,60 @@ +import type { ReactiveController } from 'lit'; + +import type { DataViewTable } from '../table-view.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({ + '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; + }, + }) + ); + } +} 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..176ef115320a --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/table/controller/selection.ts @@ -0,0 +1,1153 @@ +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 { 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, + type 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; + + // custom + focus: null | CellFocus = null; + + 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] as TableRow)?.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'); + this.clearSelection(); + } + + 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', + focusTo?: 'start' | 'end' + ) { + // if (!this.selection || this.selection.selectionType !== 'area') { + // return; + // } + if (!this.focus) { + return; + } + const cell = this.getCellContainer( + undefined, + this.focus.rowIndex, + this.focus.columnIndex + ); + if (!cell) { + return; + } + const row = cell.closest('microsheet-data-view-table-row'); + const rows = Array.from( + row + ?.closest('.affine-microsheet-table-container') + ?.querySelectorAll('microsheet-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) { + // + return false; + } else { + rowIndex--; + } + } + if (position === 'down') { + if (rowIndex === rows.length - 1) { + // + return false; + } else { + rowIndex++; + } + } + + rows[rowIndex] + ?.querySelectorAll('affine-microsheet-cell-container') + ?.item(columnIndex) + ?.selectCurrentCell( + focusTo + ? focusTo + : position === 'up' || position === 'left' + ? 'end' + : 'start' + ); + return true; + } + + 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( + `microsheet-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; + } + + 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('microsheet-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('microsheet-data-view-table-row') + .values() ?? []) { + if (!(row instanceof TableRow)) { + continue; + } + 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') as TableGroup + )?.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 isInSingleCell = ( + selection: ReturnType + ) => { + const { row, column } = selection; + return row.end - row.start === 0 && column.end - column.start === 0; + }; + 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, + }; + if (isInSingleCell(selection)) return selection; + select(selection); + return selection; + }, + // @ts-expect-error + onDrop: selection => { + if (!selection) { + return; + } + if (isInSingleCell(selection)) return selection; + 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); + if (isSelected) { + this.selection = TableRowSelection.create({ + rows: [], + }); + } else { + this.selection = TableRowSelection.create({ + rows: [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 if (selection?.selectionType === 'row') { + this.updateRowSelectionStyle(selection.rows[0]); + } 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'; + } + + 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/define.ts b/packages/affine/microsheet-data-view/src/view-presets/table/define.ts new file mode 100644 index 000000000000..6a3572aa2da6 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/table/define.ts @@ -0,0 +1,49 @@ +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[]; + 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..1e878c6b90ee --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/table/group.ts @@ -0,0 +1,180 @@ +import { + menu, + popFilterableSimpleMenu, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { BlockComponent, ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +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 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``; + } + )} +
+ `; + } + + override render() { + return this.renderRows(this.rows); + } + + protected override updated(_changedProperties: PropertyValues) { + super.updated(_changedProperties); + this.querySelectorAll('microsheet-data-view-table-row').forEach(ele => { + if (ele instanceof BlockComponent) { + 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..8ff2eefdd9e7 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/table/header/column-header.ts @@ -0,0 +1,114 @@ +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 } 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; + + 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.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` `; + } + )} +
+ `; + } + + @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..5a836eb52236 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/table/header/microsheet-header-column.ts @@ -0,0 +1,451 @@ +import { + menu, + 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 { AddCursorIcon, DeleteIcon } 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 { TableColumn, TableSingleView } from '../table-view-manager.js'; + +import { startDrag } from '../../../core/utils/drag.js'; +import { autoScrollOnBoundary } from '../../../core/utils/frame-loop.js'; +import { getResultInRange } from '../../../core/utils/utils.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 _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, + this.column.id + ); + 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) { + popMenu(popupTargetFromElement(ele ?? this), { + options: { + items: [ + menu.group({ + items: [ + menu.action({ + name: 'Delete', + prefix: DeleteIcon(), + hide: () => + !this.column.delete || this.column.type$.value === 'title', + select: () => { + this.column.delete?.(); + }, + class: { 'delete-item': true }, + }), + ], + }), + ], + }, + }); + } + + 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 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` `} +
+
+
+
{ + this.tableViewManager.propertyAdd({ + id: this.column.id, + before: true, + }); + }} + > +
+ ${AddCursorIcon()} +
+
{ + this.tableViewManager.propertyAdd({ + id: this.column.id, + before: false, + }); + }} + > +
+ ${AddCursorIcon()} +
+
+ `; + } + + @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, + id: string +) => { + const div = document.createElement('div'); + 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) { + div.style.left = `${Math.round(offset)}px`; + }, + remove() { + div.remove(); + }, + }; +}; + +declare global { + interface HTMLElementTagNameMap { + 'affine-microsheet-header-column': MicrosheetHeaderColumn; + } +} 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..0da5142282ed --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/table/header/styles.ts @@ -0,0 +1,418 @@ +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; + box-sizing: border-box; + user-select: none; + background-color: var(--affine-background-primary-color); + visibility: hidden; + } + + .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; + padding:0; + background-color: #eee; + } + + .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; + } + + .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; + padding: 0; + } + + .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; + } + + + .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; + } + + .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/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..941c98fe0156 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/table/row/row.ts @@ -0,0 +1,372 @@ +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'; +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 { 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: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); + } + .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 = (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) => { + this.view.rowAdd({ + id: this.rowId, + before: before, + }); + }; + + contextMenu = (e: MouseEvent) => { + if (this.view.readonly$.value) { + return; + } + const selection = this.selectionController; + if (!selection) { + return; + } + e.preventDefault(); + const row = { id: this.rowId, groupKey: this.groupKey }; + if (!TableRowSelection.includes(selection.selection, row)) { + selection.selection = TableRowSelection.create({ + rows: [row], + }); + } + }; + + 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; + } + + 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': true }, + }), + ], + }), + ], + }, + }); + } + + 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` + ${view.readonly$.value + ? nothing + : html`
+
+
+
+ ${AddCursorIcon()} +
+
+
+ ${AddCursorIcon()} +
+
`} + ${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 }], + }) + ); + }; + const openMenu = () => { + if (!this.selectionController) { + return; + } + const row = { id: this.rowId, groupKey: this.groupKey }; + this.setSelection( + TableRowSelection.create({ + rows: [row], + }) + ); + }; + 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 { + 'microsheet-data-view-table-row': TableRow; + } +} 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..fab201f0d58b --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/table/table-view-manager.ts @@ -0,0 +1,328 @@ +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 { defaultGroupBy } from '../../core/common/group-by.js'; +import { + GroupManager, + sortByManually, +} from '../../core/common/group-by/helper.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 => { + return this.propertyTypeGet(id) !== 'title'; + }); + }); + + 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.detailProperties$.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 + ), + }; + }); + } + + override isShow(): boolean { + 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..d170de84b449 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/view-presets/table/table-view.ts @@ -0,0 +1,307 @@ +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 { 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; + margin-left: -16px; + overflow: hidden; + } + + affine-microsheet-table * { + box-sizing: border-box; + } + + .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; + justify-content: space-between; + height: 44px; + margin: 2px 0 2px; + } + + .affine-microsheet-block-table { + position: relative; + width: 100%; + 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); + border-top: 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; + } + + .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< + 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() { + 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` +
+
+
+ ${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/index.ts b/packages/affine/microsheet-data-view/src/widget-presets/index.ts new file mode 100644 index 000000000000..ca54b0d46056 --- /dev/null +++ b/packages/affine/microsheet-data-view/src/widget-presets/index.ts @@ -0,0 +1,7 @@ +import { createWidgetTools, toolsWidgetPresets } from './tools/index.js'; + +export const widgetPresets = { + 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 new file mode 100644 index 000000000000..5cbdde7d788f --- /dev/null +++ b/packages/affine/microsheet-data-view/src/widget-presets/tools/index.ts @@ -0,0 +1,21 @@ +import type { + MicrosheetDataViewWidget, + MicrosheetDataViewWidgetProps, +} from '../../core/widget/types.js'; + +import { createUniComponentFromWebComponent } from '../../core/index.js'; +import { uniMap } from '../../core/utils/uni-component/operation.js'; +import { DataViewHeaderTools } from './tools-renderer.js'; + +export const toolsWidgetPresets = {}; +export const createWidgetTools = ( + toolsMap: Record +) => { + return uniMap( + createUniComponentFromWebComponent(DataViewHeaderTools), + (props: MicrosheetDataViewWidgetProps) => ({ + ...props, + toolsMap, + }) + ); +}; 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..0cba0ea6cb4b --- /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 { + MicrosheetDataViewWidget, + MicrosheetDataViewWidgetProps, +} 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: MicrosheetDataViewWidgetProps = { + view: this.view, + viewMethods: this.viewMethods, + }; + return renderUniLit(uni, props); + })} +
`; + } + + @state() + accessor showToolBar = false; + + @property({ attribute: false }) + accessor toolsMap!: Record; +} + +declare global { + interface HTMLElementTagNameMap { + 'microsheet-data-view-header-tools': DataViewHeaderTools; + } +} +export const renderTools = ( + view: SingleView, + viewMethods: DataViewExpose, + viewSource: ViewManager +) => { + return html``; +}; 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/attachment/attachment-model.ts b/packages/affine/model/src/blocks/attachment/attachment-model.ts index d8bcfc6f0b38..836e8cb3ab71 100644 --- a/packages/affine/model/src/blocks/attachment/attachment-model.ts +++ b/packages/affine/model/src/blocks/attachment/attachment-model.ts @@ -82,6 +82,7 @@ export const AttachmentBlockSchema = defineBlockSchema({ 'affine:edgeless-text', 'affine:paragraph', 'affine:list', + 'affine:cell', ], }, transformer: () => 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/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/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/index.ts b/packages/affine/model/src/blocks/index.ts index 67d6f1e4bbd2..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'; @@ -9,7 +10,9 @@ 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'; +export * from './row/index.js'; export * from './surface-ref/index.js'; 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/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..4b52f4d0479e --- /dev/null +++ b/packages/affine/model/src/blocks/microsheet/microsheet-model.ts @@ -0,0 +1,37 @@ +import type { Text } from '@blocksuite/store'; + +import { BlockModel, defineBlockSchema } from '@blocksuite/store'; + +import type { + MicrosheetColumn as Column, + MicrosheetSerializedCells as SerializedCells, + MicrosheetViewBasicDataType as 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: ['affine:row'], + }, + 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..52c22638166d --- /dev/null +++ b/packages/affine/model/src/blocks/microsheet/types.ts @@ -0,0 +1,27 @@ +export interface MicrosheetColumn< + Data extends Record = Record, +> { + id: string; + type: string; + name: string; + data: Data; +} + +export type MicrosheetColumnUpdater< + T extends MicrosheetColumn = MicrosheetColumn, +> = (data: T) => Partial; +export type MicrosheetCell = { + columnId: MicrosheetColumn['id']; + value: ValueType; + ref: string; +}; + +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 203fb61e21f5..3dfa897075b3 100644 --- a/packages/affine/model/src/blocks/note/note-model.ts +++ b/packages/affine/model/src/blocks/note/note-model.ts @@ -45,6 +45,8 @@ export const NoteBlockSchema = defineBlockSchema({ 'affine:code', 'affine:divider', 'affine:database', + 'affine:microsheet', + 'affine:microsheet-data-view', 'affine:data-view', 'affine:image', 'affine:bookmark', 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/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/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/package.json b/packages/blocks/package.json index a2bb5bfd60b0..763c752ecf7c 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/_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/_specs/common.ts b/packages/blocks/src/_specs/common.ts index 4f80dabb9898..f5b4f985f7a7 100644 --- a/packages/blocks/src/_specs/common.ts +++ b/packages/blocks/src/_specs/common.ts @@ -8,15 +8,19 @@ 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'; 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, } from '../note-block/note-spec.js'; +import { RowBlockSpec } from '../row-block/row-spec.js'; export const CommonFirstPartyBlockSpecs: ExtensionType[] = [ RichTextExtensions, @@ -24,7 +28,11 @@ export const CommonFirstPartyBlockSpecs: ExtensionType[] = [ ListBlockSpec, NoteBlockSpec, DatabaseBlockSpec, + MicrosheetBlockSpec, + RowBlockSpec, + CellBlockSpec, DataViewBlockSpec, + MicrosheetDataViewBlockSpec, DividerBlockSpec, CodeBlockSpec, ImageBlockSpec, @@ -40,7 +48,11 @@ export const EdgelessFirstPartyBlockSpecs: ExtensionType[] = [ ListBlockSpec, EdgelessNoteBlockSpec, DatabaseBlockSpec, + MicrosheetBlockSpec, + 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 10df7ac8ba7f..5e37a4197772 100644 --- a/packages/blocks/src/_specs/group/common.ts +++ b/packages/blocks/src/_specs/group/common.ts @@ -17,6 +17,8 @@ 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 { MicrosheetDataViewBlockSpec } from '../../microsheet-data-view-block/data-view-spec.js'; import { EdgelessNoteBlockSpec, NoteBlockSpec, @@ -39,6 +41,8 @@ export { EmbedYoutubeBlockSpec, 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 new file mode 100644 index 000000000000..c5c488d7fabf --- /dev/null +++ b/packages/blocks/src/cell-block/cell-block.ts @@ -0,0 +1,39 @@ +/// + +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'; + +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() { + return html`${this.renderChildren(this.model)}`; + } + + @property({ attribute: false }) + override accessor widgets = {}; +} + +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..784516768f39 --- /dev/null +++ b/packages/blocks/src/cell-block/cell-service.ts @@ -0,0 +1,10 @@ +import { CellBlockSchema } from '@blocksuite/affine-model'; +import { BlockService } from '@blocksuite/block-std'; + +export class CellBlockService extends BlockService { + static override readonly flavour = CellBlockSchema.model.flavour; + + override mounted(): void { + super.mounted(); + } +} 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..519bd48404df --- /dev/null +++ b/packages/blocks/src/cell-block/keymap-controller.ts @@ -0,0 +1,127 @@ +/* eslint-disable */ +import type { + BaseSelection, + BlockComponent, + UIEventHandler, + UIEventStateContext, +} from '@blocksuite/block-std'; +import type { ReactiveController } from 'lit'; + +import type { ReactiveControllerHost } from 'lit'; +import type { BlockModel } from '@blocksuite/store'; + +export const ensureBlockInContainer = ( + blockElement: BlockComponent, + containerElement: BlockComponent +) => + containerElement.contains(blockElement) && blockElement !== containerElement; + +export class KeymapController implements ReactiveController { + 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) { + 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 = () => { + const childrenModels = this.host.model.children; + if ( + this._std.selection.filter('block').length === childrenModels.length && + this._std.selection + .filter('block') + .every((block: BaseSelection) => + childrenModels.some((model: BlockModel) => model.id === block.blockId) + ) + ) { + return; + } + 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/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 7c16cbd2d9d4..01b694f3e64f 100644 --- a/packages/blocks/src/effects.ts +++ b/packages/blocks/src/effects.ts @@ -16,6 +16,7 @@ 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'; @@ -61,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, @@ -106,6 +111,11 @@ 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 { MicrosheetDataViewBlockComponent } from './microsheet-data-view-block/data-view-block.js'; import { EdgelessNoteBlockComponent, EdgelessNoteMask, @@ -298,6 +308,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, @@ -327,6 +338,7 @@ export function effects() { blockDatabaseEffects(); blockSurfaceRefEffects(); blockLatexEffects(); + microsheetDataViewEffects(); componentCaptionEffects(); componentContextMenuEffects(); @@ -384,6 +396,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); @@ -407,6 +423,9 @@ 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); @@ -716,6 +735,9 @@ declare global { 'affine:attachment': AttachmentBlockService; '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 17f0f2ff4e32..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'; @@ -29,6 +30,8 @@ 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 './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 { @@ -47,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/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..5b4a62dd7587 --- /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/microsheet-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..7951236626a8 --- /dev/null +++ b/packages/blocks/src/microsheet-block/data-source.ts @@ -0,0 +1,477 @@ +import type { MicrosheetBlockModel } from '@blocksuite/affine-model'; +import type { EditorHost } from '@blocksuite/block-std'; + +import { + insertPositionToIndex, + type InsertToPosition, +} from '@blocksuite/affine-shared/utils'; +import { assertExists } from '@blocksuite/global/utils'; +import { + DataSourceBase, + type DataViewDataType, + type PropertyMetaConfig, + type TType, + type ViewManager, + ViewManagerBase, + type ViewMeta, +} 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'; +import { + microsheetBlockAllPropertyMap, + microsheetBlockPropertyList, +} from './properties/index.js'; +import { titlePurePropertyConfig } from './properties/title/define.js'; +import { + addProperty, + applyCellsUpdate, + applyPropertyUpdate, + copyCellsByProperty, + deleteRows, + deleteView, + duplicateView, + findPropertyIndex, + getCell, + getProperty, + moveViewTo, + updateCell, + updateProperty, + updateView, +} from './utils.js'; +import { microsheetBlockViewMap, microsheetBlockViews } from './views/index.js'; + +export class MicrosheetBlockDataSource extends DataSourceBase { + private _batch = 0; + + private readonly _model: MicrosheetBlockModel; + + 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); + }); + + 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}`; + } + + cellRefGet(rowId: string, propertyId: string): unknown { + return getCell(this._model, rowId, propertyId)?.ref; + } + + 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, + ref: '', + }); + 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.textPropertyConfig.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.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); + }); + } + + 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 ?? '' + ); + } + + 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); + 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 { + this.doc.captureSync(); + for (const id of ids) { + 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); + } + + 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.textPropertyConfig.create('Tag', {}) + ); + microsheetViewInitEmpty(model, viewType); +}; +export const microsheetViewInitTemplate = ( + model: MicrosheetBlockModel, + viewType: string +) => { + 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', + { + text: new model.doc.Text(`Cell...`), + }, + cellId + ); + updateCell(model, rowId, { + columnId: columnIds[u], + value: '', + ref: cellId, + }); + } + } + 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/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..0c9aae60c070 --- /dev/null +++ b/packages/blocks/src/microsheet-block/microsheet-block.ts @@ -0,0 +1,509 @@ +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 { + menu, + popMenu, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { DragIndicator } from '@blocksuite/affine-components/drag-indicator'; +import { toast } from '@blocksuite/affine-components/toast'; +import { NOTE_SELECTOR } from '@blocksuite/affine-shared/consts'; +import { Rect, Slot } from '@blocksuite/global/utils'; +import { + CopyIcon, + DeleteIcon, + MoreHorizontalIcon, +} from '@blocksuite/icons/lit'; +import { + DataView, + dataViewCommonStyle, + type DataViewExpose, + type DataViewProps, + defineUniComponent, + type MicrosheetDataViewSelection, + type MicrosheetDataViewWidget, + type MicrosheetDataViewWidgetProps, + MicrosheetSelection, + renderUniLit, +} from '@blocksuite/microsheet-data-view'; +import { widgetPresets } from '@blocksuite/microsheet-data-view/widget-presets'; +import { Slice } from '@blocksuite/store'; +import { computed, type ReadonlySignal, 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'; +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, +} from '../root-block/index.js'; +import { getDropResult } from '../root-block/widgets/drag-handle/utils.js'; +import { HostContextKey } from './context/host-context.js'; +import { MicrosheetBlockDataSource } from './data-source.js'; +import { calculateLineNum, isInCellEnd, isInCellStart } from './utils.js'; + +export class MicrosheetBlockComponent extends CaptionedBlockComponent< + MicrosheetBlockModel, + MicrosheetBlockService +> { + static override styles = css` + ${unsafeCSS(dataViewCommonStyle('affine-microsheet'))} + affine-microsheet { + display: block; + background-color: var(--affine-background-primary-color); + } + + affine-microsheet:hover .affine-microsheet-column-header { + visibility: visible; + } + + affine-microsheet:hover .microsheet-data-view-table-left-bar { + visibility: visible; + } + affine-microsheet:hover .data-view-table-left-bar { + visibility: visible; + } + + .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': true }, + 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: MicrosheetDataViewWidget = defineUniComponent( + (props: MicrosheetDataViewWidgetProps) => { + return html` +
+
+ ${this.renderTitle(props.viewMethods)} ${this.renderMicrosheetOps()} +
+
+ ${renderUniLit(this.toolsWidget, 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 () => {}; + }; + + selectionUpdated: Slot = new Slot< + MicrosheetDataViewSelection | undefined + >(); + + setSelection = (selection: MicrosheetDataViewSelection | undefined) => { + this.selection.setGroup( + 'note', + selection + ? [ + new MicrosheetSelection({ + blockId: this.blockId, + viewSelection: selection, + }), + ] + : [] + ); + }; + + toolsWidget: MicrosheetDataViewWidget = widgetPresets.createTools({ + table: [], + }); + + 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 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 dataViewTableElement() { + return this._DataViewTableElement; + } + + get optionsConfig(): MicrosheetOptionsConfig { + return { + configure: (_model, options) => options, + // @ts-expect-error + ...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._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', 'end'); + return true; + }, + 'Shift-Tab': context => { + const selectionController = + this.dataViewTableElement?.selectionController; + if (!selectionController) return; + context.get('keyboardState').raw.preventDefault(); + selectionController.focusToCell('left', 'end'); + 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; + }, + '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; + }, + }) + ); + } + + override renderBlock() { + 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, + })} +
+ `; + } + + @query('affine-microsheet-table') + private accessor _DataViewTableElement: DataViewTable | null = null; + + 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..5fe29910af58 --- /dev/null +++ b/packages/blocks/src/microsheet-block/microsheet-service.ts @@ -0,0 +1,49 @@ +import type { Doc } from '@blocksuite/store'; + +import { + type MicrosheetBlockModel, + MicrosheetBlockSchema, +} from '@blocksuite/affine-model'; +import { BlockService } from '@blocksuite/block-std'; +import { viewPresets } from '@blocksuite/microsheet-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, microsheetId: string, viewType: string) { + const blockModel = doc.getBlock(microsheetId)?.model as + | MicrosheetBlockModel + | undefined; + if (!blockModel) { + return; + } + microsheetViewInitTemplate(blockModel, viewType); + 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..cd1c83cdb58c --- /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/microsheet-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/index.ts b/packages/blocks/src/microsheet-block/properties/index.ts new file mode 100644 index 000000000000..e67b4cad66b9 --- /dev/null +++ b/packages/blocks/src/microsheet-block/properties/index.ts @@ -0,0 +1,19 @@ +import type { PropertyMetaConfig } from '@blocksuite/microsheet-data-view'; + +import { richTextColumnConfig } from './rich-text/cell-renderer.js'; +import { titleColumnConfig } from './title/cell-renderer.js'; + +export const microsheetBlockColumns = { + richTextColumnConfig, +}; +export const microsheetBlockPropertyList = Object.values( + microsheetBlockColumns +); +export const microsheetBlockHiddenColumns = [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..69b209cc9f19 --- /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 { PenIcon } from '@blocksuite/icons/lit'; +import { + BaseCellRenderer, + createFromBaseCellRenderer, + createIcon, +} from '@blocksuite/microsheet-data-view'; +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(--microsheet-data-view-cell-text-size); + line-height: var(--microsheet-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); + } + .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; + } + .microsheet-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(--microsheet-data-view-cell-text-size); + line-height: var(--microsheet-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..48b77703214e --- /dev/null +++ b/packages/blocks/src/microsheet-block/properties/link/define.ts @@ -0,0 +1,16 @@ +import { propertyType, tString } from '@blocksuite/microsheet-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..8840ff9ebe2c --- /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 { IS_MAC } from '@blocksuite/global/env'; +import { assertExists } from '@blocksuite/global/utils'; +import { + BaseCellRenderer, + createFromBaseCellRenderer, + createIcon, +} from '@blocksuite/microsheet-data-view'; +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(--microsheet-data-view-cell-text-size); + line-height: var(--microsheet-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..49d89aebb66b --- /dev/null +++ b/packages/blocks/src/microsheet-block/properties/rich-text/define.ts @@ -0,0 +1,32 @@ +import { propertyType, tRichText } from '@blocksuite/microsheet-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..6da9faa091f4 --- /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/microsheet-data-view'; +import { TableSingleView } from '@blocksuite/microsheet-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..69b9f06470e9 --- /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/microsheet-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..3484d32ed7ed --- /dev/null +++ b/packages/blocks/src/microsheet-block/properties/title/icon.ts @@ -0,0 +1,21 @@ +import { BaseCellRenderer } from '@blocksuite/microsheet-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..725d7d89194f --- /dev/null +++ b/packages/blocks/src/microsheet-block/properties/title/text.ts @@ -0,0 +1,321 @@ +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 { IS_MAC } from '@blocksuite/global/env'; +import { assertExists } from '@blocksuite/global/utils'; +import { BaseCellRenderer } from '@blocksuite/microsheet-data-view'; +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` + microsheet-data-view-header-area-text { + width: 100%; + display: flex; + } + + microsheet-data-view-header-area-text rich-text { + pointer-events: none; + user-select: none; + } + + microsheet-data-view-header-area-text-editing { + width: 100%; + display: flex; + cursor: text; + } + + .microsheet-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(--microsheet-data-view-cell-text-size); + line-height: var(--microsheet-data-view-cell-text-line-height); + } + + .microsheet-data-view-header-area-rich-text v-line { + display: flex !important; + align-items: center; + height: 100%; + width: 100%; + } + + .microsheet-data-view-header-area-rich-text v-line > div { + flex-grow: 1; + } + + .microsheet-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); + } + + .microsheet-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="microsheet-data-view-header-area-rich-text can-link-doc" + >`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'microsheet-data-view-header-area-text': HeaderAreaTextCell; + 'microsheet-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..ea0ed571b97a --- /dev/null +++ b/packages/blocks/src/microsheet-block/utils.ts @@ -0,0 +1,357 @@ +import type { + MicrosheetCell as Cell, + MicrosheetColumn as Column, + MicrosheetColumnUpdater as ColumnUpdater, + MicrosheetBlockModel, + MicrosheetViewBasicDataType as ViewBasicDataType, +} from '@blocksuite/affine-model'; +import type { BlockStdScope, TextSelection } from '@blocksuite/block-std'; +import type { BlockModel } from '@blocksuite/store'; + +import { + arrayMove, + insertPositionToIndex, + type InsertToPosition, +} from '@blocksuite/affine-shared/utils'; +import { assertExists } from '@blocksuite/global/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 + ); + }); + 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; +} + +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, + ref: '', + }; + } + const yRow = model.cells$.value[rowId]; + const yCell = yRow?.[columnId] ?? null; + if (!yCell) return null; + + return { + ...yCell, + }; +} + +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] = { + ...cell, + // columnId: cell.columnId, + // value: cell.value, + }; + }); +} + +export function updateCells( + model: MicrosheetBlockModel, + columnId: string, + cells: Record +) { + model.doc.transact(() => { + // @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, + }; + }); + }); +} + +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', +]; + +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/microsheet-block/views/index.ts b/packages/blocks/src/microsheet-block/views/index.ts new file mode 100644 index 000000000000..4e0f06b78153 --- /dev/null +++ b/packages/blocks/src/microsheet-block/views/index.ts @@ -0,0 +1,9 @@ +import type { ViewMeta } from '@blocksuite/microsheet-data-view'; + +import { viewPresets } from '@blocksuite/microsheet-data-view/view-presets'; + +export const microsheetBlockViews: ViewMeta[] = [viewPresets.tableViewMeta]; + +export const microsheetBlockViewMap = Object.fromEntries( + microsheetBlockViews.map(view => [view.type, view]) +); 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/microsheet-data-view-block/block-meta/base.ts b/packages/blocks/src/microsheet-data-view-block/block-meta/base.ts new file mode 100644 index 000000000000..162403f518ed --- /dev/null +++ b/packages/blocks/src/microsheet-data-view-block/block-meta/base.ts @@ -0,0 +1,36 @@ +import type { Disposable } from '@blocksuite/global/utils'; +import type { PropertyMetaConfig } from '@blocksuite/microsheet-data-view'; +import type { Block, BlockModel } from '@blocksuite/store'; + +type PropertyMeta< + T extends BlockModel = BlockModel, + Value = unknown, + ColumnData extends NonNullable = NonNullable, +> = { + name: string; + key: string; + metaConfig: PropertyMetaConfig; + getColumnData?: (block: T) => ColumnData; + setColumnData?: (block: T, data: ColumnData) => void; + get: (block: T) => Value; + set?: (block: T, value: Value) => void; + updated: (block: T, callback: () => void) => Disposable; +}; +export type BlockMeta = { + selector: (block: Block) => boolean; + properties: PropertyMeta[]; +}; +export const createBlockMeta = ( + options: Omit, 'properties'> +) => { + const meta: BlockMeta = { + ...options, + properties: [], + }; + return { + ...meta, + addProperty: (property: PropertyMeta) => { + meta.properties.push(property as 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 new file mode 100644 index 000000000000..583105b7cf10 --- /dev/null +++ b/packages/blocks/src/microsheet-data-view-block/block-meta/index.ts @@ -0,0 +1,3 @@ +import type { BlockMeta } from './base.js'; + +export const blockMetaMap = {} satisfies Record; diff --git a/packages/blocks/src/microsheet-data-view-block/columns/index.ts b/packages/blocks/src/microsheet-data-view-block/columns/index.ts new file mode 100644 index 000000000000..54765a9c0efb --- /dev/null +++ b/packages/blocks/src/microsheet-data-view-block/columns/index.ts @@ -0,0 +1,12 @@ +import type { PropertyMetaConfig } from '@blocksuite/microsheet-data-view'; + +import { propertyPresets } from '@blocksuite/microsheet-data-view/property-presets'; + +import { richTextColumnConfig } from '../../database-block/properties/rich-text/cell-renderer.js'; + +export const queryBlockColumns = [propertyPresets.textPropertyConfig]; +export const queryBlockHiddenColumns = [richTextColumnConfig]; +const queryBlockAllColumns = [...queryBlockColumns, ...queryBlockHiddenColumns]; +export const queryBlockAllColumnMap = Object.fromEntries( + queryBlockAllColumns.map(v => [v.type, v as PropertyMetaConfig]) +); diff --git a/packages/blocks/src/microsheet-data-view-block/data-source.ts b/packages/blocks/src/microsheet-data-view-block/data-source.ts new file mode 100644 index 000000000000..ab7ea8d3e32a --- /dev/null +++ b/packages/blocks/src/microsheet-data-view-block/data-source.ts @@ -0,0 +1,320 @@ +import type { Column } from '@blocksuite/affine-model'; +import type { EditorHost } from '@blocksuite/block-std'; +import type { Block, Doc } from '@blocksuite/store'; + +import { + insertPositionToIndex, + type InsertToPosition, +} from '@blocksuite/affine-shared/utils'; +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 { MicrosheetDataViewBlockModel } from './data-view-model.js'; + +import { + databaseBlockAllPropertyMap, + databasePropertyConverts, +} from '../database-block/properties/index.js'; +import { blockMetaMap } from './block-meta/index.js'; +import { queryBlockAllColumnMap, queryBlockColumns } from './columns/index.js'; + +export type BlockQueryDataSourceConfig = { + type: keyof typeof blockMetaMap; +}; + +// @ts-ignore +export class BlockQueryDataSource extends DataSourceBase { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private columnMetaMap = new Map>(); + + private meta: BlockMeta; + + blockMap = new Map(); + + docDisposeMap = new Map void>(); + + slots = { + update: new Slot(), + }; + + private get blocks() { + return [...this.blockMap.values()]; + } + + get properties(): string[] { + return [ + ...this.meta.properties.map(v => v.key), + ...this.block.columns.map(v => v.id), + ]; + } + + get propertyMetas(): PropertyMetaConfig[] { + return queryBlockColumns as PropertyMetaConfig[]; + } + + get rows(): string[] { + return this.blocks.map(v => v.id); + } + + get workspace() { + return this.host.doc.collection; + } + + constructor( + private host: EditorHost, + private block: MicrosheetDataViewBlockModel, + config: BlockQueryDataSourceConfig + ) { + super(); + this.meta = blockMetaMap[config.type]; + for (const property of this.meta.properties) { + this.columnMetaMap.set(property.metaConfig.type, property.metaConfig); + } + for (const collection of this.workspace.docs.values()) { + for (const block of Object.values(collection.getDoc().blocks.peek())) { + if (this.meta.selector(block)) { + this.blockMap.set(block.id, block); + } + } + } + this.workspace.docs.forEach(doc => { + this.listenToDoc(doc.getDoc()); + }); + this.workspace.slots.docAdded.on(id => { + const doc = this.workspace.getDoc(id); + if (doc) { + this.listenToDoc(doc); + } + }); + this.workspace.slots.docRemoved.on(id => { + this.docDisposeMap.get(id)?.(); + }); + } + + private getProperty(propertyId: string) { + const property = this.meta.properties.find(v => v.key === propertyId); + assertExists(property, `property ${propertyId} not found`); + return property; + } + + private newColumnName() { + let i = 1; + while (this.block.columns.some(column => column.name === `Column ${i}`)) { + i++; + } + return `Column ${i}`; + } + + cellRefGet(rowId: string, propertyId: string): string { + 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 { + const viewColumn = this.getViewColumn(propertyId); + if (viewColumn) { + this.block.cells[rowId] = { + ...this.block.cells[rowId], + [propertyId]: value, + }; + return; + } + const block = this.blockMap.get(rowId); + if (block) { + this.meta.properties + .find(v => v.key === propertyId) + ?.set?.(block.model, value); + } + } + + cellValueGet(rowId: string, propertyId: string): unknown { + const viewColumn = this.getViewColumn(propertyId); + if (viewColumn) { + return this.block.cells[rowId]?.[propertyId]; + } + const block = this.blockMap.get(rowId); + if (block) { + return this.getProperty(propertyId)?.get(block.model); + } + return; + } + + getViewColumn(id: string) { + return this.block.columns.find(v => v.id === id); + } + + listenToDoc(doc: Doc) { + this.docDisposeMap.set( + doc.id, + doc.slots.blockUpdated.on(v => { + if (v.type === 'add') { + const blockById = doc.getBlock(v.id); + if (blockById && this.meta.selector(blockById)) { + this.blockMap.set(v.id, blockById); + } + } else if (v.type === 'delete') { + this.blockMap.delete(v.id); + } + this.slots.update.emit(); + }).dispose + ); + } + + propertyAdd( + insertToPosition: InsertToPosition, + type: string | undefined + ): string { + const doc = this.block.doc; + doc.captureSync(); + const column = databaseBlockAllPropertyMap[ + type ?? propertyPresets.textPropertyConfig.type + ].create(this.newColumnName()); + + const id = doc.generateBlockId(); + if (this.block.columns.some(v => v.id === id)) { + return id; + } + doc.transact(() => { + const col: Column = { + ...column, + id, + }; + this.block.columns.splice( + insertPositionToIndex(insertToPosition, this.block.columns), + 0, + col + ); + }); + return id; + } + + propertyDataGet(propertyId: string): Record { + const viewColumn = this.getViewColumn(propertyId); + if (viewColumn) { + return viewColumn.data; + } + const property = this.getProperty(propertyId); + return ( + property.getColumnData?.(this.blocks[0].model) ?? + property.metaConfig.config.defaultData() + ); + } + + propertyDataSet(propertyId: string, data: Record): void { + const viewColumn = this.getViewColumn(propertyId); + if (viewColumn) { + viewColumn.data = data; + } + } + + propertyDelete(_id: string): void { + const index = this.block.columns.findIndex(v => v.id === _id); + if (index >= 0) { + this.block.columns.splice(index, 1); + } + } + + propertyDuplicate(_columnId: string): string { + throw new Error('Method not implemented.'); + } + + propertyMetaGet(type: string): PropertyMetaConfig { + const meta = this.columnMetaMap.get(type); + if (meta) { + return meta; + } + return queryBlockAllColumnMap[type]; + } + + propertyNameGet(propertyId: string): string { + const viewColumn = this.getViewColumn(propertyId); + if (viewColumn) { + return viewColumn.name; + } + if (propertyId === 'type') { + return 'Block Type'; + } + return this.getProperty(propertyId)?.name ?? ''; + } + + propertyNameSet(propertyId: string, name: string): void { + const viewColumn = this.getViewColumn(propertyId); + if (viewColumn) { + viewColumn.name = name; + } + } + + override propertyReadonlyGet(propertyId: string): boolean { + const viewColumn = this.getViewColumn(propertyId); + if (viewColumn) { + return false; + } + if (propertyId === 'type') return true; + return this.getProperty(propertyId)?.set == null; + } + + propertyTypeGet(propertyId: string): string { + const viewColumn = this.getViewColumn(propertyId); + if (viewColumn) { + return viewColumn.type; + } + if (propertyId === 'type') { + return 'image'; + } + return this.getProperty(propertyId).metaConfig.type; + } + + propertyTypeSet(propertyId: string, toType: string): void { + const viewColumn = this.getViewColumn(propertyId); + if (viewColumn) { + const currentType = viewColumn.type; + const currentData = viewColumn.data; + const rows = this.rows$.value; + const currentCells = rows.map(rowId => + this.cellValueGet(rowId, propertyId) + ); + const convertFunction = databasePropertyConverts.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: databaseBlockAllPropertyMap[toType].config.defaultData(), + cells: currentCells.map(() => undefined), + }; + this.block.doc.captureSync(); + viewColumn.type = toType; + viewColumn.data = result.property; + currentCells.forEach((value, i) => { + if (value != null || result.cells[i] != null) { + this.block.cells[rows[i]] = { + ...this.block.cells[rows[i]], + [propertyId]: result.cells[i], + }; + } + }); + } + } + + rowAdd(_insertPosition: InsertToPosition | number): string { + throw new Error('Method not implemented.'); + } + + rowDelete(_ids: string[]): void { + throw new Error('Method not implemented.'); + } + + rowMove(_rowId: string, _position: InsertToPosition): void {} +} 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 new file mode 100644 index 000000000000..0464efd916ed --- /dev/null +++ b/packages/blocks/src/microsheet-data-view-block/data-view-block.ts @@ -0,0 +1,250 @@ +import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption'; +import { + menu, + popMenu, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { + CopyIcon, + DeleteIcon, + MoreHorizontalIcon, +} from '@blocksuite/affine-components/icons'; +import { RANGE_SYNC_EXCLUDE_ATTR } from '@blocksuite/block-std'; +import { + type DataSource, + DataView, + dataViewCommonStyle, + type DataViewProps, + defineUniComponent, + type MicrosheetDataViewSelection, + type MicrosheetDataViewWidget, + type MicrosheetDataViewWidgetProps, + MicrosheetSelection, + renderUniLit, +} from '@blocksuite/microsheet-data-view'; +import { widgetPresets } from '@blocksuite/microsheet-data-view/widget-presets'; +import { Slice } from '@blocksuite/store'; +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 { MicrosheetDataViewBlockModel } from './data-view-model.js'; + +import { + EdgelessRootBlockComponent, + type RootService, +} from '../root-block/index.js'; + +export class MicrosheetDataViewBlockComponent extends CaptionedBlockComponent { + static override styles = css` + ${unsafeCSS(dataViewCommonStyle('affine-database'))} + affine-database { + display: block; + border-radius: 8px; + background-color: var(--affine-background-primary-color); + padding: 8px; + margin: 8px -8px -8px; + } + + .database-block-selected { + background-color: var(--affine-hover-color); + border-radius: 4px; + } + + .database-ops { + margin-top: 4px; + padding: 2px; + border-radius: 4px; + display: flex; + cursor: pointer; + } + + .database-ops svg { + width: 16px; + height: 16px; + color: var(--affine-icon-color); + } + + .database-ops:hover { + background-color: var(--affine-hover-color); + } + + @media print { + .database-ops { + display: none; + } + + .database-header-bar { + display: none !important; + } + } + `; + + private _clickDatabaseOps = (e: MouseEvent) => { + popMenu(popupTargetFromElement(e.currentTarget as HTMLElement), { + options: { + items: [ + menu.input({ + initialValue: this.model.title, + placeholder: 'Untitled', + onComplete: text => { + this.model.title = text; + }, + }), + menu.action({ + prefix: CopyIcon, + name: 'Copy', + select: () => { + const slice = Slice.fromModels(this.doc, [this.model]); + this.std.clipboard.copySlice(slice).catch(console.error); + }, + }), + menu.group({ + name: '', + items: [ + menu.action({ + prefix: DeleteIcon, + class: { 'delete-item': true }, + name: 'Delete Database', + select: () => { + this.model.children.slice().forEach(block => { + this.doc.deleteBlock(block); + }); + this.doc.deleteBlock(this.model); + }, + }), + ], + }), + ], + }, + }); + }; + + private _dataSource?: DataSource; + + private dataView = new DataView(); + + _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: MicrosheetDataViewWidget = defineUniComponent( + (props: MicrosheetDataViewWidgetProps) => { + return html` +
+
+
${this.model.title}
+ ${this.renderDatabaseOps()} +
+
+ ${renderUniLit(this.toolsWidget, props)} +
+
+ `; + } + ); + + selection$: ReadonlySignal = 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 as MicrosheetDataViewSelection; + }); + + setSelection = (selection: MicrosheetDataViewSelection | undefined) => { + this.selection.setGroup( + 'note', + selection + ? [ + new MicrosheetSelection({ + blockId: this.blockId, + viewSelection: selection, + }), + ] + : [] + ); + }; + + toolsWidget = widgetPresets.createTools({ + table: [], + }); + + get dataSource(): DataSource { + return this._dataSource as DataSource; + } + + override get topContenteditableElement() { + if (this.rootComponent instanceof EdgelessRootBlockComponent) { + const note = this.closest('affine-note'); + return note; + } + return this.rootComponent; + } + + get view() { + return this.dataView.expose; + } + + private renderDatabaseOps() { + if (this.doc.readonly) { + return nothing; + } + return html`
+ ${MoreHorizontalIcon} +
`; + } + + override connectedCallback() { + super.connectedCallback(); + + this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true'); + } + + override renderBlock() { + return html` +
+ ${this.dataView.render({ + virtualPadding$: signal(0), + bindHotkey: this._bindHotkey, + handleEvent: this._handleEvent, + selection$: this.selection$, + setSelection: this.setSelection, + dataSource: this.dataSource, + headerWidget: this.headerWidget, + std: this.std, + })} +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-microsheet-data-view': MicrosheetDataViewBlockComponent; + } +} diff --git a/packages/blocks/src/microsheet-data-view-block/data-view-model.ts b/packages/blocks/src/microsheet-data-view-block/data-view-model.ts new file mode 100644 index 000000000000..3fb48f6b1558 --- /dev/null +++ b/packages/blocks/src/microsheet-data-view-block/data-view-model.ts @@ -0,0 +1,96 @@ +import type { Column } from '@blocksuite/affine-model'; +import type { DataViewDataType } from '@blocksuite/microsheet-data-view'; + +import { + arrayMove, + insertPositionToIndex, + type InsertToPosition, +} from '@blocksuite/affine-shared/utils'; +import { BlockModel, defineBlockSchema } from '@blocksuite/store'; + +type Props = { + title: string; + views: DataViewDataType[]; + columns: Column[]; + cells: Record>; +}; + +export class MicrosheetDataViewBlockModel extends BlockModel { + constructor() { + super(); + } + + applyViewsUpdate() { + this.doc.updateBlock(this, { + views: this.views, + }); + } + + deleteView(id: string) { + this.doc.captureSync(); + this.doc.transact(() => { + this.views = this.views.filter(v => v.id !== id); + }); + } + + duplicateView(id: string): string { + const newId = this.doc.generateBlockId(); + this.doc.transact(() => { + const index = this.views.findIndex(v => v.id === id); + const view = this.views[index]; + if (view) { + this.views.splice( + index + 1, + 0, + JSON.parse(JSON.stringify({ ...view, id: newId })) + ); + } + }); + return newId; + } + + moveViewTo(id: string, position: InsertToPosition) { + this.doc.transact(() => { + this.views = arrayMove( + this.views, + v => v.id === id, + arr => insertPositionToIndex(position, arr) + ); + }); + this.applyViewsUpdate(); + } + + updateView( + id: string, + update: (data: DataViewDataType) => Partial + ) { + this.doc.transact(() => { + this.views = this.views.map(v => { + if (v.id !== id) { + return v; + } + return { ...v, ...(update(v) as DataViewDataType) }; + }); + }); + this.applyViewsUpdate(); + } +} + +export const MicrosheetDataViewBlockSchema = defineBlockSchema({ + flavour: 'affine:microsheet-data-view', + props: (): Props => ({ + views: [], + title: '', + columns: [], + cells: {}, + }), + metadata: { + role: 'hub', + version: 1, + parent: ['affine:note'], + children: ['affine:paragraph', 'affine:list'], + }, + toModel: () => { + 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 new file mode 100644 index 000000000000..9c8c516a4634 --- /dev/null +++ b/packages/blocks/src/microsheet-data-view-block/data-view-spec.ts @@ -0,0 +1,17 @@ +import { + BlockViewExtension, + type ExtensionType, + FlavourExtension, +} from '@blocksuite/block-std'; +import { literal } from 'lit/static-html.js'; + +import { MicrosheetDataViewBlockService } from './microsheet-service.js'; + +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/index.ts b/packages/blocks/src/microsheet-data-view-block/index.ts new file mode 100644 index 000000000000..e0a82084d385 --- /dev/null +++ b/packages/blocks/src/microsheet-data-view-block/index.ts @@ -0,0 +1,12 @@ +import type { MicrosheetDataViewBlockModel } from './data-view-model.js'; + +export * from './data-view-block.js'; +export * from './data-view-model.js'; + +declare global { + namespace BlockSuite { + interface BlockModels { + '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/utils.ts b/packages/blocks/src/microsheet-data-view-block/utils.ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/blocks/src/microsheet-data-view-block/views/index.ts b/packages/blocks/src/microsheet-data-view-block/views/index.ts new file mode 100644 index 000000000000..59ac89911948 --- /dev/null +++ b/packages/blocks/src/microsheet-data-view-block/views/index.ts @@ -0,0 +1,9 @@ +import type { ViewMeta } from '@blocksuite/microsheet-data-view'; + +import { viewPresets } from '@blocksuite/microsheet-data-view/view-presets'; + +export const blockQueryViews: ViewMeta[] = [viewPresets.tableViewMeta]; + +export const blockQueryViewMap = Object.fromEntries( + blockQueryViews.map(view => [view.type, view]) +); diff --git a/packages/blocks/src/root-block/clipboard/adapter.ts b/packages/blocks/src/root-block/clipboard/adapter.ts index 342282a17fab..7401934cbafa 100644 --- a/packages/blocks/src/root-block/clipboard/adapter.ts +++ b/packages/blocks/src/root-block/clipboard/adapter.ts @@ -1,3 +1,4 @@ +import type { CellBlockModel } from '@blocksuite/affine-model'; import type { BlockSnapshot, DocSnapshot, @@ -16,6 +17,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 +93,261 @@ 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 { + // @ts-expect-error + 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', + // @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()], + }; + + 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]); + // @ts-expect-error + cell.addChildren(this.getCellContent(i, j)); + } + } + } + + toSnapshotContent() { + // @ts-expect-error + return { + type: 'block', + id: nanoid(), + flavour: 'affine:microsheet', + version: 1, + 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/config.ts b/packages/blocks/src/root-block/widgets/slash-menu/config.ts index 69f2418f6783..ad787520b976 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, @@ -537,6 +538,7 @@ export const defaultSlashMenuConfig: SlashMenuConfig = { // --------------------------------------------------------- { groupName: 'Database' }, + { name: 'Table View', description: 'Display items in a table format.', @@ -610,6 +612,30 @@ export const defaultSlashMenuConfig: SlashMenuConfig = { .run(); }, }, + { + name: 'Normal Table', + description: 'Display items in a table format.', + alias: ['database'], + 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); + }, + }, // --------------------------------------------------------- { groupName: 'Actions' }, 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..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,10 +80,46 @@ 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'); } +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/row-block/index.ts b/packages/blocks/src/row-block/index.ts new file mode 100644 index 000000000000..253dfa8e49bc --- /dev/null +++ b/packages/blocks/src/row-block/index.ts @@ -0,0 +1,16 @@ +import type { RowBlockModel } from '@blocksuite/affine-model'; + +import type { RowBlockService } from './row-service.js'; + +export * from './row-block.js'; +export * from './row-service.js'; +declare global { + namespace BlockSuite { + interface BlockServices { + 'affine:row': RowBlockService; + } + interface BlockModels { + 'affine:row': RowBlockModel; + } + } +} diff --git a/packages/blocks/src/row-block/row-block.ts b/packages/blocks/src/row-block/row-block.ts new file mode 100644 index 000000000000..a238e2df391c --- /dev/null +++ b/packages/blocks/src/row-block/row-block.ts @@ -0,0 +1,62 @@ +/// + +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 { RowBlockService } from './row-service.js'; + +export class RowBlockComponent extends CaptionedBlockComponent< + RowBlockModel, + RowBlockService +> { + static override styles = css` + affine-row > .database-cell { + 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); + } + .affine-row-block-container { + display: flow-root; + } + .affine-row-block-container.selected { + background-color: var(--affine-hover-color); + } + `; + + override renderBlock() { + const { view, rowId, rowIndex, std } = this; + return html``; + } + + @property({ attribute: false }) + accessor rowId!: string; + + @property({ attribute: false }) + accessor rowIndex!: number; + + @property({ attribute: false }) + accessor view!: TableSingleView; + + @property({ attribute: false }) + override accessor widgets = {}; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-row': RowBlockComponent; + } +} diff --git a/packages/blocks/src/row-block/row-service.ts b/packages/blocks/src/row-block/row-service.ts new file mode 100644 index 000000000000..7e8c7024a510 --- /dev/null +++ b/packages/blocks/src/row-block/row-service.ts @@ -0,0 +1,10 @@ +import { RowBlockSchema } from '@blocksuite/affine-model'; +import { BlockService } from '@blocksuite/block-std'; + +export class RowBlockService extends BlockService { + static override readonly flavour = RowBlockSchema.model.flavour; + + override mounted() { + super.mounted(); + } +} diff --git a/packages/blocks/src/row-block/row-spec.ts b/packages/blocks/src/row-block/row-spec.ts new file mode 100644 index 000000000000..29ea9433e085 --- /dev/null +++ b/packages/blocks/src/row-block/row-spec.ts @@ -0,0 +1,14 @@ +import { + BlockViewExtension, + type ExtensionType, + FlavourExtension, +} from '@blocksuite/block-std'; +import { literal } from 'lit/static-html.js'; + +import { RowBlockService } from './row-service.js'; + +export const RowBlockSpec: ExtensionType[] = [ + FlavourExtension('affine:row'), + RowBlockService, + BlockViewExtension('affine:row', literal`affine-row`), +]; diff --git a/packages/blocks/src/row-block/styles.ts b/packages/blocks/src/row-block/styles.ts new file mode 100644 index 000000000000..9eb1df55a2d7 --- /dev/null +++ b/packages/blocks/src/row-block/styles.ts @@ -0,0 +1,3 @@ +import { css } from 'lit'; + +export const rowBlockStyles = css``; diff --git a/packages/blocks/src/schemas.ts b/packages/blocks/src/schemas.ts index 0fb0cf9a702e..349982d5d04a 100644 --- a/packages/blocks/src/schemas.ts +++ b/packages/blocks/src/schemas.ts @@ -6,6 +6,7 @@ import { SurfaceBlockSchema } from '@blocksuite/affine-block-surface'; import { AttachmentBlockSchema, BookmarkBlockSchema, + CellBlockSchema, CodeBlockSchema, DatabaseBlockSchema, DividerBlockSchema, @@ -21,13 +22,16 @@ import { ImageBlockSchema, LatexBlockSchema, ListBlockSchema, + MicrosheetBlockSchema, NoteBlockSchema, ParagraphBlockSchema, RootBlockSchema, + RowBlockSchema, SurfaceRefBlockSchema, } from '@blocksuite/affine-model'; import { DataViewBlockSchema } from './data-view-block/data-view-model.js'; +import { MicrosheetDataViewBlockSchema } from './microsheet-data-view-block/data-view-model.js'; /** Built-in first party block models built for affine */ export const AffineSchemas: z.infer[] = [ @@ -42,8 +46,12 @@ export const AffineSchemas: z.infer[] = [ BookmarkBlockSchema, FrameBlockSchema, DatabaseBlockSchema, + MicrosheetBlockSchema, + RowBlockSchema, + CellBlockSchema, SurfaceRefBlockSchema, DataViewBlockSchema, + MicrosheetDataViewBlockSchema, AttachmentBlockSchema, EmbedYoutubeBlockSchema, EmbedFigmaBlockSchema, 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/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/framework/store/src/transformer/job.ts b/packages/framework/store/src/transformer/job.ts index 55068ced7a8e..7a9211e5cc02 100644 --- a/packages/framework/store/src/transformer/job.ts +++ b/packages/framework/store/src/transformer/job.ts @@ -199,6 +199,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({ @@ -279,13 +337,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({ @@ -437,6 +496,7 @@ export class Job { return { id: flat.snapshot.id, flavour: flat.snapshot.flavour, + // children: flat.snapshot.children, children: [], ...props, } as DraftModel; diff --git a/packages/playground/package.json b/packages/playground/package.json index 77bbe16ff51f..6d010a3001cd 100644 --- a/packages/playground/package.json +++ b/packages/playground/package.json @@ -18,6 +18,7 @@ "@blocksuite/data-view": "workspace:*", "@blocksuite/global": "workspace:*", "@blocksuite/inline": "workspace:*", + "@blocksuite/microsheet-data-view": "workspace:*", "@blocksuite/presets": "workspace:*", "@blocksuite/store": "workspace:*", "@blocksuite/sync": "workspace:*", diff --git a/packages/playground/vite.config.ts b/packages/playground/vite.config.ts index 1b8e054fdc45..f24744d594c0 100644 --- a/packages/playground/vite.config.ts +++ b/packages/playground/vite.config.ts @@ -102,6 +102,7 @@ const chunkGroups = { require.resolve('@blocksuite/affine-block-paragraph'), require.resolve('@blocksuite/affine-block-surface'), require.resolve('@blocksuite/data-view'), + require.resolve('@blocksuite/microsheet-data-view'), ], datefns: [path.dirname(require.resolve('date-fns'))], dompurify: [path.dirname(require.resolve('dompurify'))], @@ -257,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/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'); diff --git a/tests/utils/actions/misc.ts b/tests/utils/actions/misc.ts index 09c048baa179..dacf198f051a 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; diff --git a/yarn.lock b/yarn.lock index 77eee02ec936..4fd6df775617 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1699,6 +1699,7 @@ __metadata: "@blocksuite/global": "workspace:*" "@blocksuite/icons": "npm:^2.1.68" "@blocksuite/inline": "workspace:*" + "@blocksuite/microsheet-data-view": "workspace:*" "@blocksuite/store": "workspace:*" "@floating-ui/dom": "npm:^1.6.10" "@lit/context": "npm:^1.1.2" @@ -1833,6 +1834,28 @@ __metadata: languageName: unknown linkType: soft +"@blocksuite/microsheet-data-view@workspace:*, @blocksuite/microsheet-data-view@workspace:packages/affine/microsheet-data-view": + version: 0.0.0-use.local + resolution: "@blocksuite/microsheet-data-view@workspace:packages/affine/microsheet-data-view" + dependencies: + "@blocksuite/affine-components": "workspace:*" + "@blocksuite/affine-shared": "workspace:*" + "@blocksuite/block-std": "workspace:*" + "@blocksuite/global": "workspace:*" + "@blocksuite/icons": "npm:^2.1.68" + "@blocksuite/store": "workspace:*" + "@floating-ui/dom": "npm:^1.6.10" + "@lit/context": "npm:^1.1.2" + "@preact/signals-core": "npm:^1.8.0" + "@toeverything/theme": "npm:^1.0.8" + "@types/sortablejs": "npm:^1.15.8" + date-fns: "npm:^4.0.0" + lit: "npm:^3.2.0" + sortablejs: "npm:^1.15.2" + zod: "npm:^3.23.8" + languageName: unknown + linkType: soft + "@blocksuite/playground@workspace:packages/playground": version: 0.0.0-use.local resolution: "@blocksuite/playground@workspace:packages/playground" @@ -1844,6 +1867,7 @@ __metadata: "@blocksuite/data-view": "workspace:*" "@blocksuite/global": "workspace:*" "@blocksuite/inline": "workspace:*" + "@blocksuite/microsheet-data-view": "workspace:*" "@blocksuite/presets": "workspace:*" "@blocksuite/store": "workspace:*" "@blocksuite/sync": "workspace:*" @@ -4527,10 +4551,10 @@ __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 +"@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 languageName: node linkType: hard @@ -5025,6 +5049,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" @@ -13358,6 +13389,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"